From 716039894690af96ce3f4f16ae42d793fc2a1d4c Mon Sep 17 00:00:00 2001 From: James Strong <54325531+heisenbugged@users.noreply.github.com> Date: Fri, 8 May 2026 10:11:49 -0500 Subject: [PATCH] feat: migrate auxiliary persistence to postgres --- .dockerignore | 2 + .github/workflows/deploy-to-production.yml | 3 +- .github/workflows/restart-bot.yml | 3 +- .github/workflows/start-bot.yml | 3 +- .gitignore | 1 + Dockerfile | 1 + docker-compose.production.yml | 7 + pom.xml | 20 +- scripts/migrate-sqlite-to-postgres.sh | 280 +++++++++++ scripts/pgloader-ti4-transforms.lisp | 19 + .../entities/CombatCandidateEventEntity.java | 6 +- .../entities/CombatReplayContestEntity.java | 10 +- .../CombatReplayLeaderboardEntryEntity.java | 7 +- .../persistence/DatabasePersistenceGate.java | 13 +- .../api/dashboard/PlayerAggregatesCache.java | 4 +- .../deploy/ActiveLeaseTransactionService.java | 20 +- .../service/persistence/GameEntity.java | 13 +- .../service/persistence/PlayerEntity.java | 6 +- .../service/persistence/TitleEntity.java | 8 +- .../roundstats/GameRoundPlayerStats.java | 5 +- .../GameRoundPlayerStatsSnapshot.java | 5 +- src/main/resources/config/application.yml | 18 +- .../V1__baseline_postgresql_schema.sql | 441 ++++++++++++++++++ .../api/image/GameImageServiceTest.java | 5 +- 24 files changed, 868 insertions(+), 32 deletions(-) create mode 100755 scripts/migrate-sqlite-to-postgres.sh create mode 100644 scripts/pgloader-ti4-transforms.lisp create mode 100644 src/main/resources/db/migration/V1__baseline_postgresql_schema.sql diff --git a/.dockerignore b/.dockerignore index 97141f1e7a..4c5a7ba910 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,4 +9,6 @@ node_modules src/main/resources/** !src/main/resources/config/ !src/main/resources/config/application.yml +!src/main/resources/db/ +!src/main/resources/db/** !src/main/resources/logback.xml diff --git a/.github/workflows/deploy-to-production.yml b/.github/workflows/deploy-to-production.yml index 571230ee98..b9172b600b 100644 --- a/.github/workflows/deploy-to-production.yml +++ b/.github/workflows/deploy-to-production.yml @@ -62,6 +62,7 @@ jobs: REPO_DISPATCH_TOKEN: ${{ secrets.REPOSITORY_DISPATCH_TOKEN }} TI4_ULTIMATE_STATISTICS_API_KEY: ${{ secrets.TI4_ULTIMATE_STATISTICS_API_KEY }} MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY: ${{ secrets.MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} DISCORD_REDIRECT_URI: ${{ secrets.DISCORD_REDIRECT_URI }} DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }} DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }} @@ -74,7 +75,7 @@ jobs: password: ${{ secrets.HOSTINGER_SSH_PASSWORD }} port: ${{ secrets.HOSTINGER_SSH_PORT }} command_timeout: 30m - envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, DISCORD_WEBHOOK_URL, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION + envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, POSTGRES_PASSWORD, DISCORD_WEBHOOK_URL, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION script: | set -eu diff --git a/.github/workflows/restart-bot.yml b/.github/workflows/restart-bot.yml index 03a7f1b090..d8508f3a1e 100644 --- a/.github/workflows/restart-bot.yml +++ b/.github/workflows/restart-bot.yml @@ -19,6 +19,7 @@ jobs: REPO_DISPATCH_TOKEN: ${{ secrets.REPOSITORY_DISPATCH_TOKEN }} TI4_ULTIMATE_STATISTICS_API_KEY: ${{ secrets.TI4_ULTIMATE_STATISTICS_API_KEY }} MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY: ${{ secrets.MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} DISCORD_REDIRECT_URI: ${{ secrets.DISCORD_REDIRECT_URI }} DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }} DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }} @@ -31,7 +32,7 @@ jobs: password: ${{ secrets.HOSTINGER_SSH_PASSWORD }} port: ${{ secrets.HOSTINGER_SSH_PORT }} command_timeout: 30m - envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION + envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, POSTGRES_PASSWORD, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION script: | set -eu diff --git a/.github/workflows/start-bot.yml b/.github/workflows/start-bot.yml index c81da5fa1b..b515638eb5 100644 --- a/.github/workflows/start-bot.yml +++ b/.github/workflows/start-bot.yml @@ -19,6 +19,7 @@ jobs: REPO_DISPATCH_TOKEN: ${{ secrets.REPOSITORY_DISPATCH_TOKEN }} TI4_ULTIMATE_STATISTICS_API_KEY: ${{ secrets.TI4_ULTIMATE_STATISTICS_API_KEY }} MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY: ${{ secrets.MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} DISCORD_REDIRECT_URI: ${{ secrets.DISCORD_REDIRECT_URI }} DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }} DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }} @@ -30,7 +31,7 @@ jobs: username: ${{ secrets.HOSTINGER_SSH_USER }} password: ${{ secrets.HOSTINGER_SSH_PASSWORD }} port: ${{ secrets.HOSTINGER_SSH_PORT }} - envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION + envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, POSTGRES_PASSWORD, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION script: | set -eu diff --git a/.gitignore b/.gitignore index a740fac065..e715cc1946 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ runlocally clean.ps1 runlocally.ps1 *.tar *.log +*.fasl *test-save.txt src/test/resources/maps/undo tibot.db diff --git a/Dockerfile b/Dockerfile index e527518318..a194891690 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ RUN --mount=type=cache,target=/root/.m2 \ # add sources late for better layer reuse # Only package classpath config; runtime assets are mounted from the host repo. COPY src/main/resources/config ./src/main/resources/config +COPY src/main/resources/db ./src/main/resources/db COPY src/main/resources/logback.xml ./src/main/resources/logback.xml COPY src/main/java ./src/main/java diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 3cefa3f6e6..ec3ca27cbb 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -17,6 +17,8 @@ services: image: ${IMAGE_REPOSITORY:-ghcr.io/asyncti4/ti4-map-generator-bot}:${IMAGE_TAG:-master} restart: unless-stopped mem_limit: 7g + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - ${HOST_TI4_SAVES_DIR}:/opt/STORAGE - ${HOST_TI4_REPO_DIR}/src/main/resources:/opt/resources:ro @@ -36,6 +38,11 @@ services: ROLLBAR_ACCESS_TOKEN: ${ROLLBAR_ACCESS_TOKEN} ROLLBAR_CODE_VERSION: ${ROLLBAR_CODE_VERSION} ROLLBAR_ENVIRONMENT: ${ROLLBAR_ENVIRONMENT} + SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL:-jdbc:postgresql://host.docker.internal:5432/tibot} + SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME:-tibot} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + SPRING_FLYWAY_ENABLED: ${SPRING_FLYWAY_ENABLED:-true} + SPRING_JPA_HIBERNATE_DDL_AUTO: ${SPRING_JPA_HIBERNATE_DDL_AUTO:-validate} TI4_ULTIMATE_STATISTICS_API_KEY: ${TI4_ULTIMATE_STATISTICS_API_KEY} healthcheck: test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8081/api/public/ping >/dev/null 2>&1"] diff --git a/pom.xml b/pom.xml index 6ec29e5721..d11b3634d1 100644 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,6 @@ 1.18.46 6.4.1 2.44.0 - 7.3.2.Final 0.10.2 1.1 0.10.2 @@ -71,13 +70,22 @@ - org.xerial - sqlite-jdbc + org.postgresql + postgresql + runtime - org.hibernate.orm - hibernate-community-dialects - ${hibernate-dialects.version} + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-flyway + + + org.flywaydb + flyway-database-postgresql diff --git a/scripts/migrate-sqlite-to-postgres.sh b/scripts/migrate-sqlite-to-postgres.sh new file mode 100755 index 0000000000..4f8e7a29b5 --- /dev/null +++ b/scripts/migrate-sqlite-to-postgres.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +SQLITE_BIN="${SQLITE_BIN:-/usr/bin/sqlite3}" +PGLOADER_BIN="${PGLOADER_BIN:-pgloader}" +PSQL_BIN="${PSQL_BIN:-psql}" + +SQLITE_DB="${SQLITE_DB:-}" +POSTGRES_URL="${POSTGRES_URL:-${DATABASE_URL:-}}" + +if [[ -z "$SQLITE_DB" ]]; then + echo "SQLITE_DB must point at the source tibot.db file." >&2 + exit 2 +fi + +if [[ -z "$POSTGRES_URL" && "${SPRING_DATASOURCE_URL:-}" == jdbc:postgresql://* ]]; then + if [[ -z "${SPRING_DATASOURCE_USERNAME:-}" || -z "${SPRING_DATASOURCE_PASSWORD:-}" ]]; then + echo "Set POSTGRES_URL, or set SPRING_DATASOURCE_URL plus SPRING_DATASOURCE_USERNAME/PASSWORD." >&2 + exit 2 + fi + POSTGRES_URL="postgresql://${SPRING_DATASOURCE_USERNAME}:${SPRING_DATASOURCE_PASSWORD}@${SPRING_DATASOURCE_URL#jdbc:postgresql://}" +fi + +if [[ -z "$POSTGRES_URL" ]]; then + echo "POSTGRES_URL must point at the target Postgres database, e.g. postgresql://tibot:tibot@localhost:5432/tibot." >&2 + exit 2 +fi + +if [[ ! -f "$SQLITE_DB" ]]; then + echo "SQLite database not found: $SQLITE_DB" >&2 + exit 2 +fi + +if [[ "$SQLITE_DB" == *"'"* ]]; then + echo "SQLITE_DB paths containing single quotes are not supported by this migration script." >&2 + exit 2 +fi + +if ! command -v "$SQLITE_BIN" >/dev/null; then + echo "sqlite3 not found: $SQLITE_BIN" >&2 + exit 2 +fi + +if ! command -v "$PGLOADER_BIN" >/dev/null; then + echo "pgloader not found: $PGLOADER_BIN" >&2 + exit 2 +fi + +if ! command -v "$PSQL_BIN" >/dev/null; then + echo "psql not found: $PSQL_BIN" >&2 + exit 2 +fi + +TABLES=( + bot_discord_message + combat_candidate + combat_candidate_event + combat_contest + combat_contest_side_bets + combat_observation + combat_replay_hacan_market_compact_decision + combat_replay_hacan_subsidy + combat_replay_hacan_subsidy_vote + combat_replay_hacan_trade_convoys + combat_replay_hacan_trade_convoys_vote + combat_replay_house + combat_replay_house_ability_use + combat_replay_house_ability_vote + combat_replay_house_opt_out + combat_replay_house_score + combat_replay_leaderboard_entry + combat_replay_prediction + discord_user + game + game_round_player_stats + game_round_player_stats_snapshot + map_image_data + player + player_aggregates + player_map_image_data + standalone_title + title + tournament_winner +) + +FKS=( + "player fk_player_user foreign key (user_id) references discord_user (id)" + "player fk_player_game foreign key (game_name) references game (game_name)" + "title fk_title_user foreign key (user_id) references discord_user (id)" + "title fk_title_game foreign key (game_name) references game (game_name)" + "standalone_title fk_standalone_title_user foreign key (user_id) references discord_user (id)" +) + +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/ti4-pgloader.XXXXXX")" +SANITIZED_DB="$WORKDIR/tibot.pgloader.sqlite" +LOAD_FILE="$WORKDIR/sqlite-to-postgres.load" +SOURCE_COUNTS="$WORKDIR/source-counts.tsv" +TARGET_COUNTS="$WORKDIR/target-counts.tsv" +FKS_DROPPED=0 + +cleanup() { + if [[ "${TI4_MIGRATION_KEEP_WORKDIR:-0}" == "1" ]]; then + echo "Keeping migration workdir: $WORKDIR" + else + rm -rf "$WORKDIR" + fi +} +trap cleanup EXIT + +restore_foreign_keys_on_error() { + if [[ "$FKS_DROPPED" == "1" ]]; then + echo "Migration failed after foreign keys were dropped; attempting to restore them..." >&2 + add_foreign_keys || true + fi +} +trap restore_foreign_keys_on_error ERR + +quote_csv_tables() { + local first=1 + for table in "${TABLES[@]}"; do + if [[ "$first" == 1 ]]; then + printf " '%s'" "$table" + first=0 + else + printf ",\n '%s'" "$table" + fi + done + printf "\n" +} + +run_psql() { + "$PSQL_BIN" "$POSTGRES_URL" -v ON_ERROR_STOP=1 "$@" +} + +drop_foreign_keys() { + for fk in "${FKS[@]}"; do + read -r table name _ <<<"$fk" + run_psql -c "alter table if exists ${table} drop constraint if exists ${name};" + done +} + +add_foreign_keys() { + for fk in "${FKS[@]}"; do + read -r table name definition <<<"$fk" + run_psql -c "alter table ${table} add constraint ${name} ${definition};" + done +} + +echo "Checking SQLite source integrity..." +"$SQLITE_BIN" "file:$SQLITE_DB?mode=ro" "pragma integrity_check;" + +echo "Checking required target tables..." +for table in "${TABLES[@]}"; do + exists="$(run_psql -At -c "select to_regclass('public.${table}') is not null;")" + if [[ "$exists" != "t" ]]; then + echo "Missing target table public.${table}. Apply V1__baseline_postgresql_schema.sql first." >&2 + exit 1 + fi +done + +echo "Creating sanitized SQLite copy for pgloader..." +"$SQLITE_BIN" "$SQLITE_DB" "vacuum into '$SANITIZED_DB';" + +if "$SQLITE_BIN" "$SANITIZED_DB" "select 1 from pragma_table_info('player') where name = 'username';" | grep -q 1; then + "$SQLITE_BIN" "$SANITIZED_DB" "alter table player drop column username;" +fi + +if "$SQLITE_BIN" "$SANITIZED_DB" "select count(*) from sqlite_master where type = 'table' and name = 'standalone_title';" | grep -q 1; then + "$SQLITE_BIN" "$SANITIZED_DB" <<'SQL' +create table standalone_title_dedup ( + id integer, + source varchar(255) not null, + title varchar(255) not null, + user_id varchar(255) not null +); +insert into standalone_title_dedup (id, source, title, user_id) +select min(id), source, title, user_id + from standalone_title + group by user_id, title, source; +drop table standalone_title; +alter table standalone_title_dedup rename to standalone_title; +SQL +fi + +for table in "${TABLES[@]}"; do + exists="$("$SQLITE_BIN" "$SANITIZED_DB" "select count(*) from sqlite_master where type = 'table' and name = '$table';")" + if [[ "$exists" != "1" ]]; then + echo "Missing source table ${table} in $SQLITE_DB." >&2 + exit 1 + fi +done + +{ + echo "load database" + echo " from sqlite://$SANITIZED_DB" + echo " into $POSTGRES_URL" + echo + echo "with data only, reset sequences" + echo + echo "cast" + cat <<'CASTS' + column combat_candidate.started_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_candidate.resolved_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_candidate.promoted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_candidate.pending_resolution_started_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_candidate.mentak_preview_posted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_candidate_event.occurred_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_contest.posted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_contest.side_bet_market_posted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_contest.replay_start_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_contest.next_replay_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_contest.replay_completed_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_contest.pre_replay_context_posted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_contest.leaderboard_posted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_contest_side_bets.placed_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_contest_side_bets.resolved_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_observation.started_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_hacan_market_compact_decision.decided_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_hacan_subsidy.selected_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_hacan_subsidy_vote.voted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_hacan_trade_convoys.selected_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_hacan_trade_convoys_vote.voted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_house.assigned_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_house.updated_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_house_ability_use.used_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_house_ability_vote.voted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_house_opt_out.assigned_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_house_opt_out.opted_out_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_house_score.scored_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_leaderboard_entry.updated_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_prediction.announced_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_prediction.locked_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column combat_replay_prediction.scored_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp, + column player_map_image_data.updated_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp +CASTS + echo + echo "including only table names like" + quote_csv_tables + echo ";" +} > "$LOAD_FILE" + +echo "Recording source row counts..." +for table in "${TABLES[@]}"; do + count="$("$SQLITE_BIN" "$SANITIZED_DB" "select count(*) from \"$table\";")" + printf "%s\t%s\n" "$table" "$count" >> "$SOURCE_COUNTS" +done + +if [[ "${TI4_MIGRATION_TRUNCATE_TARGET:-1}" == "1" ]]; then + echo "Truncating target tables..." + table_csv="$(printf ", public.%s" "${TABLES[@]}")" + run_psql -c "truncate table ${table_csv#, } restart identity cascade;" +fi + +echo "Dropping app foreign keys during bulk load..." +drop_foreign_keys +FKS_DROPPED=1 + +echo "Running pgloader..." +"$PGLOADER_BIN" -l "$ROOT_DIR/scripts/pgloader-ti4-transforms.lisp" "$LOAD_FILE" + +echo "Re-adding app foreign keys..." +add_foreign_keys +FKS_DROPPED=0 + +echo "Recording target row counts..." +for table in "${TABLES[@]}"; do + count="$(run_psql -At -c "select count(*) from public.${table};")" + printf "%s\t%s\n" "$table" "$count" >> "$TARGET_COUNTS" +done + +if ! diff -u "$SOURCE_COUNTS" "$TARGET_COUNTS"; then + echo "Row count mismatch after migration." >&2 + echo "Source counts: $SOURCE_COUNTS" >&2 + echo "Target counts: $TARGET_COUNTS" >&2 + exit 1 +fi + +echo "Migration complete. Row counts match for ${#TABLES[@]} tables." diff --git a/scripts/pgloader-ti4-transforms.lisp b/scripts/pgloader-ti4-transforms.lisp new file mode 100644 index 0000000000..7e1a05a48e --- /dev/null +++ b/scripts/pgloader-ti4-transforms.lisp @@ -0,0 +1,19 @@ +(in-package #:pgloader.transforms) + +(defun ti4-sqlite-millis-or-timestamp-to-timestamp (value) + (cond + ((null value) nil) + ((and (stringp value) (= 0 (length value))) nil) + ((integerp value) (ti4-millis-to-timestamp value)) + ((and (stringp value) (every #'digit-char-p value)) + (ti4-millis-to-timestamp (parse-integer value))) + (t value))) + +(defun ti4-millis-to-timestamp (millis) + (let* ((seconds (floor millis 1000)) + (milliseconds (mod millis 1000)) + (unix-epoch-in-universal-time 2208988800)) + (multiple-value-bind (second minute hour day month year) + (decode-universal-time (+ unix-epoch-in-universal-time seconds) 0) + (format nil "~4,'0d-~2,'0d-~2,'0d ~2,'0d:~2,'0d:~2,'0d.~3,'0d" + year month day hour minute second milliseconds)))) diff --git a/src/main/java/ti4/contest/replay/entities/CombatCandidateEventEntity.java b/src/main/java/ti4/contest/replay/entities/CombatCandidateEventEntity.java index bd53584f5d..a538c34dca 100644 --- a/src/main/java/ti4/contest/replay/entities/CombatCandidateEventEntity.java +++ b/src/main/java/ti4/contest/replay/entities/CombatCandidateEventEntity.java @@ -22,7 +22,11 @@ name = "idx_combat_candidate_event_candidate_sequence", columnList = "candidate_id, sequence_number", unique = true), - @Index(name = "idx_combat_candidate_event_candidate_occurred_at", columnList = "candidate_id, occurred_at") + @Index(name = "idx_combat_candidate_event_candidate_occurred_at", columnList = "candidate_id, occurred_at"), + @Index( + name = "idx_combat_candidate_event_candidate_type", + columnList = "candidate_id, event_type, actor_faction, round_number"), + @Index(name = "idx_combat_candidate_event_occurred_at", columnList = "occurred_at") }) /** * Persists the ordered replay event stream for a single combat candidate. diff --git a/src/main/java/ti4/contest/replay/entities/CombatReplayContestEntity.java b/src/main/java/ti4/contest/replay/entities/CombatReplayContestEntity.java index 9b0f9758c3..47623ce5f7 100644 --- a/src/main/java/ti4/contest/replay/entities/CombatReplayContestEntity.java +++ b/src/main/java/ti4/contest/replay/entities/CombatReplayContestEntity.java @@ -19,7 +19,15 @@ name = "combat_contest", indexes = { @Index(name = "idx_replay_contest_replay_status_due", columnList = "replay_status, next_replay_at"), - @Index(name = "idx_replay_contest_posted_at", columnList = "posted_at") + @Index(name = "idx_replay_contest_posted_at", columnList = "posted_at"), + @Index(name = "idx_replay_contest_public_message", columnList = "public_message_id"), + @Index(name = "idx_replay_contest_public_thread", columnList = "public_thread_id"), + @Index( + name = "idx_replay_contest_side_bet_posting", + columnList = "replay_status, side_bet_market_posted_at, posted_at"), + @Index( + name = "idx_replay_contest_leaderboard_posting", + columnList = "replay_status, leaderboard_posted_at, replay_completed_at") }) /** * Tracks the public replay contest lifecycle once a candidate has been promoted. diff --git a/src/main/java/ti4/contest/replay/entities/CombatReplayLeaderboardEntryEntity.java b/src/main/java/ti4/contest/replay/entities/CombatReplayLeaderboardEntryEntity.java index 9bd8c1f968..65f42fd323 100644 --- a/src/main/java/ti4/contest/replay/entities/CombatReplayLeaderboardEntryEntity.java +++ b/src/main/java/ti4/contest/replay/entities/CombatReplayLeaderboardEntryEntity.java @@ -17,7 +17,12 @@ @Entity @Table( name = "combat_replay_leaderboard_entry", - indexes = @Index(name = "idx_replay_leaderboard_points", columnList = "total_points"), + indexes = { + @Index(name = "idx_replay_leaderboard_points", columnList = "total_points"), + @Index( + name = "idx_replay_leaderboard_rank", + columnList = "total_points, correct_predictions, prediction_count, discord_user_name") + }, uniqueConstraints = @UniqueConstraint(name = "uk_replay_leaderboard_user", columnNames = "discord_user_id")) /** * Stores cumulative replay leaderboard totals per Discord user. diff --git a/src/main/java/ti4/service/persistence/DatabasePersistenceGate.java b/src/main/java/ti4/service/persistence/DatabasePersistenceGate.java index 21a8cafb2a..294de58392 100644 --- a/src/main/java/ti4/service/persistence/DatabasePersistenceGate.java +++ b/src/main/java/ti4/service/persistence/DatabasePersistenceGate.java @@ -1,12 +1,16 @@ package ti4.service.persistence; import lombok.experimental.UtilityClass; +import org.apache.commons.lang3.StringUtils; import ti4.settings.GlobalSettings; @UtilityClass public class DatabasePersistenceGate { public static boolean isDisabled() { + if (isLocalFallbackDatabase()) { + return true; + } return GlobalSettings.ImplementedSettings.SQLITE_PERSISTENCE_DISABLED.getAsBoolean(false); } @@ -18,7 +22,14 @@ public static String statusMessage() { if (!isDisabled()) { return "Database maintenance mode is **OFF**. Database-backed features are enabled."; } - return "Database maintenance mode is **ON**. Game commands and file saves still run, but statistics, dashboard data," + String source = isLocalFallbackDatabase() ? " because no external datasource is configured" : ""; + return "Database maintenance mode is **ON**" + + source + + ". Game commands and file saves still run, but statistics, dashboard data," + " map image metadata, message cache, and SQL developer tools are temporarily paused."; } + + private static boolean isLocalFallbackDatabase() { + return StringUtils.isBlank(System.getenv("SPRING_DATASOURCE_URL")); + } } diff --git a/src/main/java/ti4/spring/api/dashboard/PlayerAggregatesCache.java b/src/main/java/ti4/spring/api/dashboard/PlayerAggregatesCache.java index 0c996ebc61..518be59d3a 100644 --- a/src/main/java/ti4/spring/api/dashboard/PlayerAggregatesCache.java +++ b/src/main/java/ti4/spring/api/dashboard/PlayerAggregatesCache.java @@ -3,7 +3,6 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; -import jakarta.persistence.Lob; import jakarta.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor; @@ -37,8 +36,7 @@ class PlayerAggregatesCache { @Column(name = "aggregates_version", nullable = false) private int aggregatesVersion; - @Lob - @Column(name = "aggregates_json", nullable = false) + @Column(name = "aggregates_json", nullable = false, columnDefinition = "TEXT") private String aggregatesJson; @Column(name = "computed_at_epoch_ms", nullable = false) diff --git a/src/main/java/ti4/spring/service/deploy/ActiveLeaseTransactionService.java b/src/main/java/ti4/spring/service/deploy/ActiveLeaseTransactionService.java index 221a505c98..b75f0ed8b5 100644 --- a/src/main/java/ti4/spring/service/deploy/ActiveLeaseTransactionService.java +++ b/src/main/java/ti4/spring/service/deploy/ActiveLeaseTransactionService.java @@ -2,6 +2,7 @@ import java.time.Instant; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,12 +17,29 @@ public class ActiveLeaseTransactionService { public boolean tryAcquireLease(String instanceId) { Instant now = Instant.now(); String leaseKey = leaseProperties.getLeaseKey(); - activeLeaseRepository.insertLeaseRowIfAbsent(leaseKey, "", Instant.EPOCH, Instant.EPOCH); + insertLeaseRowIfAbsent(leaseKey); return activeLeaseRepository.claimLease( leaseKey, instanceId, now.plusSeconds(leaseProperties.getLeaseDurationSeconds()), now, now) == 1; } + private void insertLeaseRowIfAbsent(String leaseKey) { + if (StringUtils.isBlank(System.getenv("SPRING_DATASOURCE_URL"))) { + if (activeLeaseRepository.existsById(leaseKey)) { + return; + } + var lease = new ActiveLeaseEntity(); + lease.setLeaseKey(leaseKey); + lease.setInstanceId(""); + lease.setLeaseExpiresAt(Instant.EPOCH); + lease.setHeartbeatAt(Instant.EPOCH); + activeLeaseRepository.save(lease); + return; + } + + activeLeaseRepository.insertLeaseRowIfAbsent(leaseKey, "", Instant.EPOCH, Instant.EPOCH); + } + @Transactional public boolean renewLease(String instanceId) { Instant now = Instant.now(); diff --git a/src/main/java/ti4/spring/service/persistence/GameEntity.java b/src/main/java/ti4/spring/service/persistence/GameEntity.java index cf900f54e2..68280c82a3 100644 --- a/src/main/java/ti4/spring/service/persistence/GameEntity.java +++ b/src/main/java/ti4/spring/service/persistence/GameEntity.java @@ -5,6 +5,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.util.ArrayList; @@ -18,7 +19,17 @@ @Setter @NoArgsConstructor @Entity -@Table(name = "game") +@Table( + name = "game", + indexes = { + @Index(name = "idx_game_ended_at", columnList = "ended_epoch_milliseconds"), + @Index( + name = "idx_game_completed_six_player_non_alliance", + columnList = "is_completed, is_alliance_mode, player_count, is_twilight_imperium_global_league"), + @Index( + name = "idx_game_active_tigl", + columnList = "is_twilight_imperium_global_league, ended_epoch_milliseconds") + }) public class GameEntity { @Id diff --git a/src/main/java/ti4/spring/service/persistence/PlayerEntity.java b/src/main/java/ti4/spring/service/persistence/PlayerEntity.java index 719895ce12..d6d4f6df72 100644 --- a/src/main/java/ti4/spring/service/persistence/PlayerEntity.java +++ b/src/main/java/ti4/spring/service/persistence/PlayerEntity.java @@ -6,6 +6,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -19,7 +20,10 @@ @Setter @NoArgsConstructor @Entity -@Table(name = "player", uniqueConstraints = @UniqueConstraint(columnNames = {"game_name", "user_id"})) +@Table( + name = "player", + indexes = @Index(name = "idx_player_user", columnList = "user_id"), + uniqueConstraints = @UniqueConstraint(columnNames = {"game_name", "user_id"})) public class PlayerEntity { @Id diff --git a/src/main/java/ti4/spring/service/persistence/TitleEntity.java b/src/main/java/ti4/spring/service/persistence/TitleEntity.java index 2b5ef2d956..d45fa5045f 100644 --- a/src/main/java/ti4/spring/service/persistence/TitleEntity.java +++ b/src/main/java/ti4/spring/service/persistence/TitleEntity.java @@ -6,6 +6,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -17,7 +18,12 @@ @Setter @NoArgsConstructor @Entity -@Table(name = "title") +@Table( + name = "title", + indexes = { + @Index(name = "idx_title_user", columnList = "user_id"), + @Index(name = "idx_title_game", columnList = "game_name") + }) public class TitleEntity { @Id diff --git a/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStats.java b/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStats.java index 23522c1f06..b65ab26d80 100644 --- a/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStats.java +++ b/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStats.java @@ -4,6 +4,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.IdClass; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor; @@ -12,7 +13,9 @@ @NoArgsConstructor @Entity @IdClass(GameRoundPlayerStatsId.class) -@Table(name = "game_round_player_stats") +@Table( + name = "game_round_player_stats", + indexes = @Index(name = "idx_game_round_player_stats_user_game", columnList = "user_discord_id, game_id")) public class GameRoundPlayerStats { @Id diff --git a/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStatsSnapshot.java b/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStatsSnapshot.java index 7f1350e9a8..1c207224d9 100644 --- a/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStatsSnapshot.java +++ b/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStatsSnapshot.java @@ -4,6 +4,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.IdClass; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor; @@ -12,7 +13,9 @@ @NoArgsConstructor @Entity @IdClass(GameRoundPlayerStatsSnapshotId.class) -@Table(name = "game_round_player_stats_snapshot") +@Table( + name = "game_round_player_stats_snapshot", + indexes = @Index(name = "idx_game_round_player_stats_snapshot_game_undo", columnList = "game_id, undo_index")) public class GameRoundPlayerStatsSnapshot { @Id diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index b4860b5398..4e1a915c24 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -9,18 +9,18 @@ management: spring: datasource: - url: "jdbc:sqlite:${DB_PATH:./}/tibot.db" - driver-class-name: org.sqlite.JDBC + url: ${SPRING_DATASOURCE_URL:jdbc:h2:mem:tibot;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH} + username: ${SPRING_DATASOURCE_USERNAME:sa} + password: ${SPRING_DATASOURCE_PASSWORD:} hikari: - # SQLite permits only one writer at a time; extra pooled connections increase lock contention. - maximum-pool-size: 1 - connection-init-sql: | - PRAGMA journal_mode=WAL; - PRAGMA busy_timeout=30000; - PRAGMA synchronous=NORMAL; + maximum-pool-size: ${SPRING_DATASOURCE_MAXIMUM_POOL_SIZE:10} + flyway: + enabled: ${SPRING_FLYWAY_ENABLED:false} + locations: classpath:db/migration + baseline-on-migrate: true jpa: hibernate: - ddl-auto: update + ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO:update} properties: hibernate: format_sql: false diff --git a/src/main/resources/db/migration/V1__baseline_postgresql_schema.sql b/src/main/resources/db/migration/V1__baseline_postgresql_schema.sql new file mode 100644 index 0000000000..82bfdb2c18 --- /dev/null +++ b/src/main/resources/db/migration/V1__baseline_postgresql_schema.sql @@ -0,0 +1,441 @@ +create table active_lease ( + lease_key text not null primary key, + heartbeat_at timestamp(6) with time zone, + instance_id text, + lease_expires_at timestamp(6) with time zone +); + +create table bot_discord_message ( + message_id bigint not null primary key, + content text, + created_at_epoch_millis bigint not null +); + +create table combat_observation ( + id bigint generated by default as identity primary key, + started_at timestamp(6) not null, + game_name text not null, + tile_position text not null, + attacker_faction text not null, + defender_faction text not null, + attacker_strength double precision not null, + defender_strength double precision not null, + attacker_hp double precision not null, + defender_hp double precision not null, + attacker_expected_hits double precision not null, + defender_expected_hits double precision not null, + fairness_ratio double precision not null, + joint_score double precision not null +); + +create table combat_candidate ( + id bigint generated by default as identity primary key, + observation_id bigint not null unique, + status text not null, + promotion_status text not null, + next_event_sequence integer not null, + started_at timestamp(6) not null, + resolved_at timestamp(6), + pending_resolution_started_at timestamp(6), + promoted_at timestamp(6), + mentak_preview_posted_at timestamp(6), + game_name text not null, + tile_position text not null, + attacker_faction text not null, + defender_faction text not null, + winner_faction text, + loser_faction text, + resolution_reason text, + cancellation_reason text, + pre_replay_context_text text, + initial_render_snapshot_json text, + replay_abilities_json text, + promotion_score double precision, + side_bet_compatible boolean not null default false, + attacker_destroyer_count integer not null default 0, + defender_destroyer_count integer not null default 0, + attacker_has_assault_cannon boolean not null default false, + defender_has_assault_cannon boolean not null default false, + attacker_hp double precision, + defender_hp double precision, + attacker_strength double precision, + defender_strength double precision, + attacker_rolled_afb boolean not null default false, + defender_rolled_afb boolean not null default false, + attacker_afb_whiff boolean not null default false, + defender_afb_whiff boolean not null default false, + attacker_skipped_afb boolean not null default false, + defender_skipped_afb boolean not null default false, + attacker_round_one_whiff boolean not null default false, + defender_round_one_whiff boolean not null default false, + attacker_round_one_slam boolean not null default false, + defender_round_one_slam boolean not null default false, + attacker_played_morale_boost boolean not null default false, + defender_played_morale_boost boolean not null default false, + attacker_played_shields_holding boolean not null default false, + defender_played_shields_holding boolean not null default false, + attacker_played_direct_hit boolean not null default false, + defender_played_direct_hit boolean not null default false, + attacker_played_fighter_prototype boolean not null default false, + defender_played_fighter_prototype boolean not null default false, + winner_one_hp_remaining boolean not null default false, + winner_remaining_hp double precision +); + +create table combat_candidate_event ( + id bigint generated by default as identity primary key, + candidate_id bigint not null, + occurred_at timestamp(6) not null, + sequence_number integer not null, + event_type text not null, + round_number integer, + actor_faction text, + summary_text text not null, + payload_json text +); + +create table combat_contest ( + id bigint generated by default as identity primary key, + candidate_id bigint not null unique, + posted_at timestamp(6), + public_channel_id bigint, + public_message_id bigint, + public_thread_id bigint, + side_bet_summary_message_id bigint, + side_bet_attacker_button_message_id bigint, + side_bet_defender_button_message_id bigint, + side_bet_market_posted_at timestamp(6), + side_bet_payout_model text, + replay_status text not null, + replay_start_at timestamp(6), + next_replay_at timestamp(6), + next_event_sequence integer not null, + replay_completed_at timestamp(6), + pre_replay_context_posted_at timestamp(6), + leaderboard_posted_at timestamp(6), + replay_error text +); + +create table combat_contest_side_bets ( + id bigint generated by default as identity primary key, + contest_id bigint not null, + discord_user_id text not null, + discord_user_name text not null, + bet_type text not null, + target_faction text not null, + offered_profit_points integer, + placed_at timestamp(6) not null, + resolved_at timestamp(6) +); + +create table combat_replay_hacan_market_compact_decision ( + id bigint generated by default as identity primary key, + contest_id bigint not null, + decided_at timestamp(6) not null, + constraint uk_hacan_market_compact_decision_contest unique (contest_id) +); + +create table combat_replay_hacan_subsidy ( + id bigint generated by default as identity primary key, + contest_id bigint not null, + bet_type text not null, + target_faction text not null, + vote_count integer not null, + selected_at timestamp(6) not null, + constraint uk_hacan_subsidy_contest_bet unique (contest_id, bet_type, target_faction) +); + +create table combat_replay_hacan_subsidy_vote ( + id bigint generated by default as identity primary key, + contest_id bigint not null, + discord_user_id text not null, + discord_user_name text not null, + bet_type text not null, + target_faction text not null, + voted_at timestamp(6) not null, + constraint uk_hacan_subsidy_vote_user_bet unique (contest_id, discord_user_id, bet_type, target_faction) +); + +create table combat_replay_hacan_trade_convoys ( + id bigint generated by default as identity primary key, + contest_id bigint not null, + target_house text, + favor_cost integer not null, + prediction_bonus integer not null, + vote_count integer not null, + selected_at timestamp(6) not null, + constraint uk_hacan_trade_convoys_contest unique (contest_id) +); + +create table combat_replay_hacan_trade_convoys_vote ( + id bigint generated by default as identity primary key, + contest_id bigint not null, + target_house text, + favor_cost integer not null, + prediction_bonus integer not null, + discord_user_id text not null, + discord_user_name text not null, + voted_at timestamp(6) not null, + constraint uk_hacan_trade_convoys_vote_contest_user unique (contest_id, discord_user_id) +); + +create table combat_replay_house ( + id bigint generated by default as identity primary key, + discord_user_id text not null, + discord_user_name text not null, + house text not null, + assigned_at timestamp(6) not null, + updated_at timestamp(6) not null, + constraint uk_replay_house_user unique (discord_user_id) +); + +create table combat_replay_house_ability_use ( + id bigint generated by default as identity primary key, + candidate_id bigint not null, + house text not null, + favor_cost integer not null, + discord_user_id text not null, + discord_user_name text not null, + used_at timestamp(6) not null, + constraint uk_replay_house_ability_use_candidate_house unique (candidate_id, house) +); + +create table combat_replay_house_ability_vote ( + id bigint generated by default as identity primary key, + candidate_id bigint not null, + house text not null, + option_key text not null, + discord_user_id text not null, + discord_user_name text not null, + voted_at timestamp(6) not null, + constraint uk_house_ability_vote_candidate_house_user unique (candidate_id, house, discord_user_id) +); + +create table combat_replay_house_opt_out ( + id bigint generated by default as identity primary key, + discord_user_id text not null, + discord_user_name text not null, + house text not null, + assigned_at timestamp(6) not null, + opted_out_at timestamp(6) not null, + constraint uk_replay_house_opt_out_user unique (discord_user_id) +); + +create table combat_replay_house_score ( + id bigint generated by default as identity primary key, + contest_id bigint not null, + house text not null, + prediction_points integer not null, + side_bet_points integer not null, + ability_points integer not null, + favor_points integer, + total_points integer not null, + prediction_count integer not null, + correct_predictions integer not null, + ability_breakdown_json text not null, + scored_at timestamp(6) not null, + constraint uk_replay_house_score_contest_house unique (contest_id, house) +); + +create table combat_replay_leaderboard_entry ( + id bigint generated by default as identity primary key, + discord_user_id text not null, + discord_user_name text not null, + total_points integer not null, + prediction_count integer not null, + correct_predictions integer not null, + updated_at timestamp(6) not null, + constraint uk_replay_leaderboard_user unique (discord_user_id) +); + +create table combat_replay_prediction ( + id bigint generated by default as identity primary key, + contest_id bigint not null, + locked_at timestamp(6) not null, + announced_at timestamp(6), + scored_at timestamp(6), + attacker_prediction_count integer not null, + defender_prediction_count integer not null, + mentak_prediction_override_count integer not null default 0, + attacker_predictions_json text not null, + defender_predictions_json text not null, + constraint uk_replay_prediction_contest unique (contest_id) +); + +create table discord_user ( + id text not null primary key, + name text not null +); + +create table game ( + game_name text not null primary key, + creation_epoch_milliseconds bigint not null, + ended_epoch_milliseconds bigint, + round_number integer not null, + victory_point_goal integer not null, + is_completed boolean not null, + is_prophecy_of_kings boolean not null, + is_thunders_edge boolean not null, + is_fracture_in_play boolean not null, + is_homebrew boolean not null, + is_discordant_stars_mode boolean not null, + is_absol_mode boolean not null, + is_franken_mode boolean not null, + is_alliance_mode boolean not null, + is_twilight_imperium_global_league boolean not null, + is_twilight_imperium_global_league_fractured boolean not null, + twilight_imperium_global_league_rank text, + player_count integer not null +); + +create table game_round_player_stats ( + game_id text not null, + user_discord_id text not null, + round integer not null, + max_army_cost double precision, + last_seen_army_cost double precision, + sc_picks text, + combats_initiated integer, + tacticals_with_combat integer, + planets_taken integer, + planets_stolen integer, + techs_gained text, + dice_rolled integer, + turn_times text, + primary key (game_id, round, user_discord_id) +); + +create table game_round_player_stats_snapshot ( + game_id text not null, + undo_index integer not null, + user_discord_id text not null, + round integer not null, + max_army_cost double precision, + last_seen_army_cost double precision, + sc_picks text, + combats_initiated integer, + tacticals_with_combat integer, + planets_taken integer, + planets_stolen integer, + techs_gained text, + dice_rolled integer, + turn_times text, + primary key (game_id, round, undo_index, user_discord_id) +); + +create table map_image_data ( + game_name text not null primary key, + latest_map_image_name text, + latest_discord_message_id bigint, + latest_discord_guild_id bigint, + latest_discord_channel_id bigint +); + +create table player ( + id bigint generated by default as identity primary key, + user_id text not null, + game_name text not null, + faction_name text, + score integer not null, + total_number_of_turns integer not null, + total_turn_time bigint not null, + expected_hits double precision not null, + actual_hits integer not null, + is_eliminated boolean not null, + is_winner boolean not null, + is_replaced boolean not null, + constraint uk_player_game_user unique (game_name, user_id) +); + +create table player_aggregates ( + user_id text not null primary key, + completed_games_hash text not null, + completed_game_count integer not null, + aggregates_version integer not null, + aggregates_json text not null, + computed_at_epoch_ms bigint not null, + last_error text, + last_error_at_epoch_ms bigint +); + +create table player_map_image_data ( + id bigint generated by default as identity primary key, + game_name text not null, + player_id text not null, + discord_message_id bigint, + discord_channel_id bigint, + updated_at timestamp(6), + constraint uk_player_map_image_data_game_player unique (game_name, player_id) +); + +create table title ( + id bigint generated by default as identity primary key, + game_name text not null, + user_id text not null, + title text not null +); + +create table standalone_title ( + id bigint generated by default as identity primary key, + user_id text not null, + title text not null, + source text not null, + constraint uk_standalone_title_user_title_source unique (user_id, title, source) +); + +create table tournament_winner ( + id bigint generated by default as identity primary key, + user_id text, + user_name text, + tourney_name text +); + +create index idx_combat_observation_started_at on combat_observation (started_at); +create index idx_combat_observation_game_tile_started_at on combat_observation (game_name, tile_position, started_at); +create index idx_combat_candidate_status_started_at on combat_candidate (status, started_at); +create index idx_combat_candidate_promotion_resolved_at on combat_candidate (promotion_status, resolved_at); +create index idx_combat_candidate_game_tile_status on combat_candidate (game_name, tile_position, status); +create index idx_combat_candidate_status_pending_resolution on combat_candidate (status, pending_resolution_started_at); +create index idx_combat_candidate_promoted_at on combat_candidate (promoted_at); +create unique index idx_combat_candidate_event_candidate_sequence on combat_candidate_event (candidate_id, sequence_number); +create index idx_combat_candidate_event_candidate_occurred_at on combat_candidate_event (candidate_id, occurred_at); +create index idx_combat_candidate_event_candidate_type on combat_candidate_event (candidate_id, event_type, actor_faction, round_number); +create index idx_combat_candidate_event_occurred_at on combat_candidate_event (occurred_at); +create index idx_replay_contest_replay_status_due on combat_contest (replay_status, next_replay_at); +create index idx_replay_contest_posted_at on combat_contest (posted_at); +create index idx_replay_contest_public_message on combat_contest (public_message_id); +create index idx_replay_contest_public_thread on combat_contest (public_thread_id); +create index idx_replay_contest_side_bet_posting on combat_contest (replay_status, side_bet_market_posted_at, posted_at); +create index idx_replay_contest_leaderboard_posting on combat_contest (replay_status, leaderboard_posted_at, replay_completed_at); +create index idx_side_bet_contest_user on combat_contest_side_bets (contest_id, discord_user_id); +create index idx_side_bet_resolved on combat_contest_side_bets (resolved_at); +create index idx_hacan_market_compact_decision_contest on combat_replay_hacan_market_compact_decision (contest_id); +create index idx_hacan_subsidy_contest on combat_replay_hacan_subsidy (contest_id); +create index idx_hacan_subsidy_vote_contest on combat_replay_hacan_subsidy_vote (contest_id); +create index idx_hacan_subsidy_vote_user on combat_replay_hacan_subsidy_vote (contest_id, discord_user_id); +create index idx_hacan_trade_convoys_contest on combat_replay_hacan_trade_convoys (contest_id); +create index idx_hacan_trade_convoys_vote_contest on combat_replay_hacan_trade_convoys_vote (contest_id); +create index idx_hacan_trade_convoys_vote_user on combat_replay_hacan_trade_convoys_vote (contest_id, discord_user_id); +create index idx_replay_house_house on combat_replay_house (house); +create index idx_replay_house_ability_use_house on combat_replay_house_ability_use (house); +create index idx_house_ability_vote_candidate_house on combat_replay_house_ability_vote (candidate_id, house); +create index idx_replay_house_opt_out_house on combat_replay_house_opt_out (house); +create index idx_replay_house_score_house on combat_replay_house_score (house); +create index idx_replay_leaderboard_points on combat_replay_leaderboard_entry (total_points); +create index idx_replay_leaderboard_rank on combat_replay_leaderboard_entry (total_points, correct_predictions, prediction_count, discord_user_name); +create index idx_replay_prediction_contest_id on combat_replay_prediction (contest_id); +create index idx_standalone_title_user on standalone_title (user_id); +create index idx_game_ended_at on game (ended_epoch_milliseconds); +create index idx_game_completed_six_player_non_alliance on game (is_completed, is_alliance_mode, player_count, is_twilight_imperium_global_league); +create index idx_game_active_tigl on game (is_twilight_imperium_global_league, ended_epoch_milliseconds); +create index idx_player_user on player (user_id); +create index idx_title_user on title (user_id); +create index idx_title_game on title (game_name); +create index idx_game_round_player_stats_user_game on game_round_player_stats (user_discord_id, game_id); +create index idx_game_round_player_stats_snapshot_game_undo on game_round_player_stats_snapshot (game_id, undo_index); + +alter table player add constraint fk_player_user foreign key (user_id) references discord_user (id); +alter table player add constraint fk_player_game foreign key (game_name) references game (game_name); +alter table title add constraint fk_title_user foreign key (user_id) references discord_user (id); +alter table title add constraint fk_title_game foreign key (game_name) references game (game_name); +alter table standalone_title add constraint fk_standalone_title_user foreign key (user_id) references discord_user (id); diff --git a/src/test/java/ti4/spring/api/image/GameImageServiceTest.java b/src/test/java/ti4/spring/api/image/GameImageServiceTest.java index dcc96f2998..2eb8f57d3d 100644 --- a/src/test/java/ti4/spring/api/image/GameImageServiceTest.java +++ b/src/test/java/ti4/spring/api/image/GameImageServiceTest.java @@ -14,6 +14,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import ti4.game.persistence.GameManager; +import ti4.service.persistence.DatabasePersistenceGate; class GameImageServiceTest { @@ -34,7 +35,9 @@ void saveDiscordMessagePersistsDiscordIds() { when(mapImageDataRepository.findById("pbd11223")).thenReturn(Optional.empty()); when(mapImageDataRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - try (MockedStatic gameManager = mockStatic(GameManager.class)) { + try (MockedStatic persistenceGate = mockStatic(DatabasePersistenceGate.class); + MockedStatic gameManager = mockStatic(GameManager.class)) { + persistenceGate.when(DatabasePersistenceGate::isDisabled).thenReturn(false); gameManager.when(() -> GameManager.isValid("pbd11223")).thenReturn(true); service.saveDiscordMessage("pbd11223", message);