From dcba23e5ecbb7b180a594e9e7086eae9af2712ac Mon Sep 17 00:00:00 2001 From: Matthew Date: Sun, 20 Sep 2026 12:53:18 -0500 Subject: [PATCH 01/10] feat: add personal army library schema --- .../migrations/war_archive_schema_test.go | 1 + .../schema/final_operational_contract_test.go | 44 ++++++++-- .../personal_army_storage_migration_test.go | 85 +++++++++++++++++++ .../timescale/022_personal_army_library.sql | 31 +++++++ database/timescale/README.md | 4 +- docs/final-operational-contract.md | 22 ++++- scripts/retained-api-profile.sh | 3 +- scripts/with-test-timescale.sh | 6 +- scripts/with-test-timescale.test.mjs | 9 ++ 9 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 database/schema/personal_army_storage_migration_test.go create mode 100644 database/timescale/022_personal_army_library.sql diff --git a/database/migrations/war_archive_schema_test.go b/database/migrations/war_archive_schema_test.go index 1ee2a9b..3f3cbe9 100644 --- a/database/migrations/war_archive_schema_test.go +++ b/database/migrations/war_archive_schema_test.go @@ -34,6 +34,7 @@ func TestTimescaleMigrationsAreExplicitlyNumbered(t *testing.T) { "019_unlimited_personal_bases.sql", "020_player_leaderboard_snapshots.sql", "021_inline_base_images_votes.sql", + "022_personal_army_library.sql", } if len(files) != len(want) { t.Fatalf("Timescale migrations = %v, want %v", files, want) diff --git a/database/schema/final_operational_contract_test.go b/database/schema/final_operational_contract_test.go index 44b4c67..b1d9c17 100644 --- a/database/schema/final_operational_contract_test.go +++ b/database/schema/final_operational_contract_test.go @@ -134,7 +134,7 @@ func TestBasesUseBigintRelationsAndPrivateVotes(t *testing.T) { } } -func TestPersonalBaseLibraryIsUnlimitedAndTyped(t *testing.T) { +func TestPersonalLibrariesUseCanonicalBaseAndArmyIdentity(t *testing.T) { conn := disposableConn(t) ctx := context.Background() tx, err := conn.Begin(ctx) @@ -149,9 +149,11 @@ func TestPersonalBaseLibraryIsUnlimitedAndTyped(t *testing.T) { 'https://link.clashofclans.com/en?action=OpenLayout&id=TH17%3AWB%3A'||value, 'Personal base '||value FROM generate_series(1,25) value; - INSERT INTO user_saved_bases(user_id,base_id,kind) - SELECT 'owner',id,CASE id%3 WHEN 0 THEN NULL WHEN 1 THEN 'war' ELSE 'legend' END - FROM bases WHERE description LIKE 'Personal base %'`) + INSERT INTO user_saved_bases(user_id,base_id) + SELECT 'owner',id FROM bases WHERE description LIKE 'Personal base %'; + INSERT INTO army_compositions(share_code) VALUES('u1x0'); + INSERT INTO user_saved_armies(user_id,share_code) + VALUES('owner','u1x0')`) if err != nil { t.Fatal(err) } @@ -159,8 +161,38 @@ func TestPersonalBaseLibraryIsUnlimitedAndTyped(t *testing.T) { if err = tx.QueryRow(ctx, `SELECT count(*) FROM user_saved_bases WHERE user_id='owner'`).Scan(&savedCount); err != nil || savedCount != 25 { t.Fatalf("unlimited saved rows=%d err=%v", savedCount, err) } - if _, err = tx.Exec(ctx, `UPDATE user_saved_bases SET kind='farming' WHERE user_id='owner'`); err == nil { - t.Fatal("invalid saved-base kind accepted") + var kindAbsent bool + if err = tx.QueryRow(ctx, `SELECT NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema='public' AND table_name='user_saved_bases' AND column_name='kind' + )`).Scan(&kindAbsent); err != nil || !kindAbsent { + t.Fatalf("saved-base kind still exists: %v", err) + } + var armySaved bool + if err = tx.QueryRow(ctx, `SELECT saved_at IS NOT NULL FROM user_saved_armies + WHERE user_id='owner' AND share_code='u1x0'`).Scan(&armySaved); err != nil || !armySaved { + t.Fatalf("personal army save missing: %v", err) + } + for _, q := range []string{ + `INSERT INTO user_saved_armies(user_id,share_code) VALUES('owner','u1x0')`, + `INSERT INTO user_saved_armies(user_id,share_code) VALUES('owner','missing')`, + } { + if _, err = tx.Exec(ctx, `SAVEPOINT invalid_personal_army`); err != nil { + t.Fatal(err) + } + if _, err = tx.Exec(ctx, q); err == nil { + t.Fatalf("invalid personal army accepted: %s", q) + } + if _, err = tx.Exec(ctx, `ROLLBACK TO SAVEPOINT invalid_personal_army`); err != nil { + t.Fatal(err) + } + } + if _, err = tx.Exec(ctx, `DELETE FROM user_saved_armies WHERE user_id='owner' AND share_code='u1x0'`); err != nil { + t.Fatal(err) + } + var compositionPresent bool + if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM army_compositions WHERE share_code='u1x0')`).Scan(&compositionPresent); err != nil || !compositionPresent { + t.Fatalf("unsave deleted canonical composition: %v", err) } if err = tx.Rollback(ctx); err != nil { t.Fatal(err) diff --git a/database/schema/personal_army_storage_migration_test.go b/database/schema/personal_army_storage_migration_test.go new file mode 100644 index 0000000..d43ab7b --- /dev/null +++ b/database/schema/personal_army_storage_migration_test.go @@ -0,0 +1,85 @@ +package schema + +import ( + "context" + "os" + "os/exec" + "testing" + + "github.com/jackc/pgx/v5" +) + +func TestPersonalArmyStorageMigration(t *testing.T) { + if os.Getenv("CLASHKING_DISPOSABLE_TIMESCALE") != "1" { + t.Skip("requires disposable Timescale fixture") + } + if os.Getenv("CLASHKING_TIMESCALE_PROFILE") != "baseline-021" { + t.Skip("requires baseline-021 migration profile") + } + ctx := context.Background() + conn, err := pgx.Connect(ctx, os.Getenv("TEST_DATABASE_URL")) + if err != nil { + t.Fatal(err) + } + defer conn.Close(ctx) + run := func(sql string, args ...any) { + t.Helper() + if _, err := conn.Exec(ctx, sql, args...); err != nil { + t.Fatal(err) + } + } + check := func(sql string, args ...any) { + t.Helper() + var ok bool + if err := conn.QueryRow(ctx, sql, args...).Scan(&ok); err != nil || !ok { + t.Fatalf("check failed: %s (%v)", sql, err) + } + } + reject := func(sql string, args ...any) { + t.Helper() + tx, err := conn.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(ctx) + if _, err = tx.Exec(ctx, sql, args...); err == nil { + t.Fatalf("invalid write accepted: %s", sql) + } + } + migrate := func(command ...string) { + t.Helper() + args := append([]string{"-env", "/dev/null", "-dir", "../timescale"}, command...) + cmd := exec.Command("goose", args...) + cmd.Env = append(os.Environ(), "GOOSE_DRIVER=postgres", "GOOSE_DBSTRING="+os.Getenv("TEST_DATABASE_URL"), "GOOSE_TABLE=goose_db_version") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("migration failed: %v\n%s", err, out) + } + } + + check(`SELECT max(version_id)=21 FROM goose_db_version WHERE is_applied`) + run(` + INSERT INTO auth_users(user_id,provider) VALUES('owner','discord'); + INSERT INTO bases(message_id,base_link) VALUES( + '700000000000000022', + 'https://link.clashofclans.com/en?action=OpenLayout&id=TH17%3AWB%3ATWENTYTWO' + ); + INSERT INTO user_saved_bases(user_id,base_id,kind,saved_at) + SELECT 'owner',id,'legend','2026-09-19T01:02:03Z' FROM bases + WHERE message_id='700000000000000022'; + INSERT INTO army_compositions(share_code) VALUES('u1x0')`) + + migrate("up-to", "22") + check(`SELECT max(version_id)=22 FROM goose_db_version WHERE is_applied`) + check(`SELECT saved_at='2026-09-19T01:02:03Z'::timestamptz FROM user_saved_bases + WHERE user_id='owner' AND base_id=(SELECT id FROM bases WHERE message_id='700000000000000022')`) + check(`SELECT NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema='public' AND table_name='user_saved_bases' AND column_name='kind' + ) AND to_regclass('public.user_saved_armies') IS NOT NULL`) + run(`INSERT INTO user_saved_armies(user_id,share_code) VALUES('owner','u1x0')`) + check(`SELECT saved_at IS NOT NULL FROM user_saved_armies WHERE user_id='owner' AND share_code='u1x0'`) + reject(`INSERT INTO user_saved_armies(user_id,share_code) VALUES('owner','u1x0')`) + reject(`INSERT INTO user_saved_armies(user_id,share_code) VALUES('owner','missing')`) + run(`DELETE FROM user_saved_armies WHERE user_id='owner' AND share_code='u1x0'`) + check(`SELECT EXISTS(SELECT 1 FROM army_compositions WHERE share_code='u1x0')`) +} diff --git a/database/timescale/022_personal_army_library.sql b/database/timescale/022_personal_army_library.sql new file mode 100644 index 0000000..8ee2472 --- /dev/null +++ b/database/timescale/022_personal_army_library.sql @@ -0,0 +1,31 @@ +-- +goose Up +-- Personal base grouping is derived from the Town Hall encoded in base_link. +-- Keep the saved-base identity and timestamp while removing the obsolete label. +ALTER TABLE public.user_saved_bases + DROP CONSTRAINT user_saved_bases_kind_check, + DROP COLUMN kind; + +-- A personal army is a user-owned reference to one canonical composition. +-- Removing the save never removes the shared composition or retained history. +CREATE TABLE public.user_saved_armies ( + user_id text NOT NULL REFERENCES public.auth_users(user_id) ON DELETE CASCADE, + share_code text NOT NULL REFERENCES public.army_compositions(share_code) ON DELETE CASCADE, + saved_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id,share_code) +); +CREATE INDEX idx_user_saved_armies_recent + ON public.user_saved_armies (user_id,saved_at DESC,share_code); + +COMMENT ON TABLE public.user_saved_armies IS + 'Authenticated-user references to canonical army compositions; share_code is the durable identity.'; + +-- +goose Down +-- Removed personal-base labels cannot be reconstructed, so this migration is +-- intentionally forward-only. +-- +goose StatementBegin +DO $$ +BEGIN + RAISE EXCEPTION 'migration 022 is irreversible: removed personal base labels cannot be reconstructed'; +END +$$; +-- +goose StatementEnd diff --git a/database/timescale/README.md b/database/timescale/README.md index fe3e44b..f43a98d 100644 --- a/database/timescale/README.md +++ b/database/timescale/README.md @@ -199,7 +199,9 @@ aggregate without changing the retained war and league analytics. `017_final_operational_contract.sql` removes compatibility storage and establishes the final cross-repository contracts. `018_personal_base_library.sql` introduces authenticated saved-base references, and `019_unlimited_personal_bases.sql` supersedes -its slot model with an unlimited nullable-kind library and base-owned download history. +its slot model with an unlimited library and base-owned download history. Migration +`022_personal_army_library.sql` removes the obsolete saved-base kind label and adds +authenticated saved-army references to canonical compositions. Do not use the former 007–028 fixture numbering. See [the schema decisions](../../docs/worker-api-schema.md), [the disposable upgrade test](../../RETAINED_API_FIXTURE.md). diff --git a/docs/final-operational-contract.md b/docs/final-operational-contract.md index d454331..bb2278a 100644 --- a/docs/final-operational-contract.md +++ b/docs/final-operational-contract.md @@ -53,7 +53,7 @@ First-click conversion is a two-phase state machine without a legacy status colu ### Unlimited personal library -`user_saved_bases` is the authenticated user's unlimited durable library. Its identity is `(user_id,base_id)`, where `user_id` references `auth_users` and `base_id` references the existing shared `bases` row. Nullable text `kind` is exactly `war`, `legend`, or NULL when the user has not labeled it. No base link, image, Discord location, vote, or count is duplicated. +`user_saved_bases` is the authenticated user's unlimited durable library. Its identity is `(user_id,base_id)`, where `user_id` references `auth_users` and `base_id` references the existing shared `bases` row. No base link, image, Discord location, vote, count, or War/Legend label is duplicated. Consumers derive Town Hall grouping from `bases.base_link`. `bases.downloads` is a JSON object keyed by Discord user ID, with the immutable first-download ISO timestamp as its value. Migration 019 validates the object shape and timestamp values, and rejects removal or replacement of an existing key. A repeated posted-button click leaves the first timestamp unchanged while the personal save upsert can restore a deleted `user_saved_bases` row. @@ -61,11 +61,29 @@ Unsave and the 90-day personal-library cleanup delete only `user_saved_bases`; l ```json { - "savedBase": {"baseId":"42","kind":"legend","savedAt":"2026-09-11T06:00:00Z"}, + "savedBase": {"baseId":"42","savedAt":"2026-09-11T06:00:00Z"}, "downloads": {"123456789012345678":"2026-09-10T18:05:04.123Z"} } ``` +### Personal army library + +`user_saved_armies` gives each authenticated user durable references to canonical +`army_compositions`. Its identity is `(user_id,share_code)`, and `saved_at` +defaults to the current time. The recent index orders each user's rows by +`saved_at DESC,share_code`. + +Deleting a personal army row never deletes the referenced composition or battle +history. Composition deletion may clear dangling saved references through its +foreign key. API writers derive the immutable Clash army link from `share_code`; +there is no duplicated link column. + +```json +{ + "shareCode":"u1x0-...","armyLink":"https://link.clashofclans.com/en?action=CopyArmy&army=u1x0-...","savedAt":"2026-09-20T12:00:00Z" +} +``` + ## War and CWL storage Migration 017 does not change war or CWL storage. `wars` retains its `archive_pack_id`, `archive_offset`, and `archive_compressed_bytes` locator triple; `war_archive_packs` and `war_archive_pending` remain authoritative for wars.clashk.ing pack publication. Canonical CWL groups, clans, members, rounds, wars, standings, and history remain available to `/v2/stats/cwl`. Migration 016 removes only the rejected `cwl_season_statistics` table, procedure, and validator, so no consumer may write or read that surface. diff --git a/scripts/retained-api-profile.sh b/scripts/retained-api-profile.sh index 938c631..5893fb4 100755 --- a/scripts/retained-api-profile.sh +++ b/scripts/retained-api-profile.sh @@ -1,5 +1,5 @@ # Canonical migration inventory for retained API tests and local development. -# This is the same contiguous 001-021 sequence used for production upgrades. +# This is the same contiguous 001-022 sequence used for production upgrades. fixture_image='timescale/timescaledb:2.29.2-pg18@sha256:9508616d5b941ed931198504c5db3fb47e8f53f790732ea1e889591f1062057c' fixture_sources=( 001_initial_stats.sql @@ -23,4 +23,5 @@ fixture_sources=( 019_unlimited_personal_bases.sql 020_player_leaderboard_snapshots.sql 021_inline_base_images_votes.sql + 022_personal_army_library.sql ) diff --git a/scripts/with-test-timescale.sh b/scripts/with-test-timescale.sh index 9481f49..3948148 100755 --- a/scripts/with-test-timescale.sh +++ b/scripts/with-test-timescale.sh @@ -2,8 +2,8 @@ # Run one integration suite against an isolated, Goose-migrated Timescale database. set -euo pipefail -if [[ ${1:-} != --profile || ( ${2:-} != retained-api && ${2:-} != baseline-006 && ${2:-} != baseline-013 && ${2:-} != baseline-018 ) ]]; then - echo 'An explicit --profile retained-api, baseline-006, baseline-013, or baseline-018 is required.' >&2 +if [[ ${1:-} != --profile || ( ${2:-} != retained-api && ${2:-} != baseline-006 && ${2:-} != baseline-013 && ${2:-} != baseline-018 && ${2:-} != baseline-021 ) ]]; then + echo 'An explicit --profile retained-api, baseline-006, baseline-013, baseline-018, or baseline-021 is required.' >&2 exit 2 fi fixture_profile="$2" @@ -22,6 +22,8 @@ elif [[ $fixture_profile == baseline-013 ]]; then fixture_sources=("${fixture_sources[@]:0:13}") elif [[ $fixture_profile == baseline-018 ]]; then fixture_sources=("${fixture_sources[@]:0:18}") +elif [[ $fixture_profile == baseline-021 ]]; then + fixture_sources=("${fixture_sources[@]:0:21}") fi fixture_container='' fixture_child='' diff --git a/scripts/with-test-timescale.test.mjs b/scripts/with-test-timescale.test.mjs index f777592..e64839f 100644 --- a/scripts/with-test-timescale.test.mjs +++ b/scripts/with-test-timescale.test.mjs @@ -85,6 +85,7 @@ test('uses only retained authoritative migrations and its own disposable contain '019_unlimited_personal_bases.sql', '020_player_leaderboard_snapshots.sql', '021_inline_base_images_votes.sql', + '022_personal_army_library.sql', ]); } assert.equal(migrations[1].args.at(-1), 'up'); @@ -108,6 +109,14 @@ test('supports a migration-018 compatibility baseline', () => { assert.equal(migration.migrations.length, 18); }); +test('supports a migration-021 compatibility baseline', () => { + const result = runFixture({}, 'test "$CLASHKING_TIMESCALE_PROFILE" = baseline-021', ['--profile', 'baseline-021']); + assert.equal(result.status, 0, result.stderr); + const migration = result.calls.find(call => call.tool === 'goose' && call.args.includes('up')); + assert.equal(migration.migrations.at(-1), '021_inline_base_images_votes.sql'); + assert.equal(migration.migrations.length, 21); +}); + test('preserves a failing child exit code and still cleans up', () => { const result = runFixture({}, 'exit 42'); assert.equal(result.status, 42); From 8ab2642014e2ddf2afb0696bdebd96e6232d9c5e Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 25 Sep 2026 09:50:42 -0500 Subject: [PATCH 02/10] feat(database): add roster and analytics migrations --- .../legend_daily_metadata_schema_test.go | 36 +++++++ .../migrations/war_archive_schema_test.go | 14 +++ .../timescale/023_roster_signup_scope.sql | 20 ++++ .../024_roster_admission_settings.sql | 20 ++++ .../025_roster_discord_publications.sql | 19 ++++ .../026_roster_publication_webhooks.sql | 6 ++ database/timescale/027_roster_embed_color.sql | 6 ++ .../028_roster_automation_event_offsets.sql | 6 ++ .../timescale/029_roster_default_capacity.sql | 5 + .../timescale/030_legend_daily_metadata.sql | 94 +++++++++++++++++++ .../timescale/031_legend_daily_pet_combos.sql | 52 ++++++++++ .../032_remove_server_link_token_policy.sql | 9 ++ database/timescale/033_cwl_participation.sql | 30 ++++++ database/timescale/034_daily_army_setups.sql | 44 +++++++++ .../035_remove_army_analysis_timestamp.sql | 17 ++++ .../timescale/036_army_setup_siege_usage.sql | 11 +++ docs/worker-api-schema.md | 4 +- scripts/retained-api-profile.sh | 16 +++- scripts/with-test-timescale.test.mjs | 14 +++ 19 files changed, 420 insertions(+), 3 deletions(-) create mode 100644 database/migrations/legend_daily_metadata_schema_test.go create mode 100644 database/timescale/023_roster_signup_scope.sql create mode 100644 database/timescale/024_roster_admission_settings.sql create mode 100644 database/timescale/025_roster_discord_publications.sql create mode 100644 database/timescale/026_roster_publication_webhooks.sql create mode 100644 database/timescale/027_roster_embed_color.sql create mode 100644 database/timescale/028_roster_automation_event_offsets.sql create mode 100644 database/timescale/029_roster_default_capacity.sql create mode 100644 database/timescale/030_legend_daily_metadata.sql create mode 100644 database/timescale/031_legend_daily_pet_combos.sql create mode 100644 database/timescale/032_remove_server_link_token_policy.sql create mode 100644 database/timescale/033_cwl_participation.sql create mode 100644 database/timescale/034_daily_army_setups.sql create mode 100644 database/timescale/035_remove_army_analysis_timestamp.sql create mode 100644 database/timescale/036_army_setup_siege_usage.sql diff --git a/database/migrations/legend_daily_metadata_schema_test.go b/database/migrations/legend_daily_metadata_schema_test.go new file mode 100644 index 0000000..2167bde --- /dev/null +++ b/database/migrations/legend_daily_metadata_schema_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +func TestLegendDailyMetadataMigrationIsAdditive(t *testing.T) { + raw, err := os.ReadFile("../timescale/030_legend_daily_metadata.sql") + if err != nil { + t.Fatal(err) + } + sql := string(raw) + for _, expected := range []string{ + "'top_200','top_100'", + "ADD COLUMN troop_stats jsonb NOT NULL DEFAULT '[]'::jsonb", + "ADD COLUMN spell_stats jsonb NOT NULL DEFAULT '[]'::jsonb", + "ADD COLUMN siege_stats jsonb NOT NULL DEFAULT '[]'::jsonb", + "ADD COLUMN equipment_pair_stats jsonb NOT NULL DEFAULT '[]'::jsonb", + "equipment_pair_usage_triples_within_attack_count", + } { + if !strings.Contains(sql, expected) { + t.Fatalf("migration is missing %q", expected) + } + } + petRaw, err := os.ReadFile("../timescale/031_legend_daily_pet_combos.sql") + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"ADD COLUMN pet_combo_stats jsonb NOT NULL DEFAULT '[]'::jsonb", "pet_combo_usage_triples_within_attack_count"} { + if !strings.Contains(string(petRaw), expected) { + t.Fatalf("migration 031 is missing %q", expected) + } + } +} diff --git a/database/migrations/war_archive_schema_test.go b/database/migrations/war_archive_schema_test.go index 3f3cbe9..bf7aeeb 100644 --- a/database/migrations/war_archive_schema_test.go +++ b/database/migrations/war_archive_schema_test.go @@ -35,6 +35,20 @@ func TestTimescaleMigrationsAreExplicitlyNumbered(t *testing.T) { "020_player_leaderboard_snapshots.sql", "021_inline_base_images_votes.sql", "022_personal_army_library.sql", + "023_roster_signup_scope.sql", + "024_roster_admission_settings.sql", + "025_roster_discord_publications.sql", + "026_roster_publication_webhooks.sql", + "027_roster_embed_color.sql", + "028_roster_automation_event_offsets.sql", + "029_roster_default_capacity.sql", + "030_legend_daily_metadata.sql", + "031_legend_daily_pet_combos.sql", + "032_remove_server_link_token_policy.sql", + "033_cwl_participation.sql", + "034_daily_army_setups.sql", + "035_remove_army_analysis_timestamp.sql", + "036_army_setup_siege_usage.sql", } if len(files) != len(want) { t.Fatalf("Timescale migrations = %v, want %v", files, want) diff --git a/database/timescale/023_roster_signup_scope.sql b/database/timescale/023_roster_signup_scope.sql new file mode 100644 index 0000000..c3eb9f6 --- /dev/null +++ b/database/timescale/023_roster_signup_scope.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- Preserve existing restrictions while giving new rosters an open signup scope. +UPDATE public.rosters SET signup_scope = 'family-only' WHERE signup_scope = 'family-wide'; +ALTER TABLE public.rosters + ALTER COLUMN signup_scope SET DEFAULT 'anyone', + ADD CONSTRAINT rosters_signup_scope_check + CHECK (signup_scope IN ('clan-only', 'family-only', 'anyone')); + +COMMENT ON COLUMN public.rosters.signup_scope IS + 'Clan eligibility for self-signup: selected clan, any server-linked clan, or anyone. Account verification and other signup limits still apply.'; + +-- +goose Down +-- Reverting cannot represent unrestricted signups without changing eligibility. +-- +goose StatementBegin +DO $$ +BEGIN + RAISE EXCEPTION 'migration 023 is forward-only: the previous schema cannot represent anyone signup scope'; +END +$$; +-- +goose StatementEnd diff --git a/database/timescale/024_roster_admission_settings.sql b/database/timescale/024_roster_admission_settings.sql new file mode 100644 index 0000000..a42bfdf --- /dev/null +++ b/database/timescale/024_roster_admission_settings.sql @@ -0,0 +1,20 @@ +-- +goose Up +ALTER TABLE rosters + ADD COLUMN max_signups integer CHECK (max_signups BETWEEN 1 AND 500), + ADD COLUMN require_verified boolean NOT NULL DEFAULT false, + ADD COLUMN hero_red_percent smallint NOT NULL DEFAULT 50, + ADD COLUMN hero_yellow_percent smallint NOT NULL DEFAULT 75, + ADD COLUMN hero_green_percent smallint NOT NULL DEFAULT 90, + ADD CONSTRAINT roster_hero_gradient_bounds CHECK ( + 0 <= hero_red_percent AND hero_red_percent < hero_yellow_percent + AND hero_yellow_percent < hero_green_percent AND hero_green_percent <= 100 + ); + +-- +goose Down +ALTER TABLE rosters + DROP CONSTRAINT roster_hero_gradient_bounds, + DROP COLUMN hero_green_percent, + DROP COLUMN hero_yellow_percent, + DROP COLUMN hero_red_percent, + DROP COLUMN require_verified, + DROP COLUMN max_signups; diff --git a/database/timescale/025_roster_discord_publications.sql b/database/timescale/025_roster_discord_publications.sql new file mode 100644 index 0000000..88eda4e --- /dev/null +++ b/database/timescale/025_roster_discord_publications.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- A bot-authored channel message is not a webhook. Keep its delivery target +-- separate from the legacy webhook destination used by older automations. +CREATE TABLE roster_discord_publications ( + roster_id uuid PRIMARY KEY REFERENCES rosters(id) ON DELETE CASCADE, + channel_id text NOT NULL CHECK (channel_id ~ '^[0-9]{1,20}$'), + message_id text NOT NULL CHECK (message_id ~ '^[0-9]{1,20}$'), + mode text NOT NULL CHECK (mode IN ('signup', 'post')), + dashboard_url text NOT NULL, + join_label text NOT NULL CHECK (length(join_label) BETWEEN 1 AND 80), + remove_label text NOT NULL CHECK (length(remove_label) BETWEEN 1 AND 80), + view_label text NOT NULL CHECK (length(view_label) BETWEEN 1 AND 80), + needs_sync boolean NOT NULL DEFAULT false, + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(channel_id, message_id) +); + +-- +goose Down +DROP TABLE roster_discord_publications; diff --git a/database/timescale/026_roster_publication_webhooks.sql b/database/timescale/026_roster_publication_webhooks.sql new file mode 100644 index 0000000..7b4382a --- /dev/null +++ b/database/timescale/026_roster_publication_webhooks.sql @@ -0,0 +1,6 @@ +-- +goose Up +ALTER TABLE roster_discord_publications + ADD COLUMN webhook_id text CHECK (webhook_id ~ '^[0-9]{1,20}$'); + +-- +goose Down +ALTER TABLE roster_discord_publications DROP COLUMN webhook_id; diff --git a/database/timescale/027_roster_embed_color.sql b/database/timescale/027_roster_embed_color.sql new file mode 100644 index 0000000..eb7591b --- /dev/null +++ b/database/timescale/027_roster_embed_color.sql @@ -0,0 +1,6 @@ +-- +goose Up +ALTER TABLE rosters ADD COLUMN embed_color integer CHECK (embed_color BETWEEN 0 AND 16777215); +COMMENT ON COLUMN rosters.embed_color IS 'Optional Discord embed color override; NULL inherits the server embed color.'; + +-- +goose Down +ALTER TABLE rosters DROP COLUMN embed_color; diff --git a/database/timescale/028_roster_automation_event_offsets.sql b/database/timescale/028_roster_automation_event_offsets.sql new file mode 100644 index 0000000..f1870cd --- /dev/null +++ b/database/timescale/028_roster_automation_event_offsets.sql @@ -0,0 +1,6 @@ +-- +goose Up +ALTER TABLE roster_automation_rules ADD COLUMN event_offset_days integer CHECK (event_offset_days BETWEEN -365 AND 365); +COMMENT ON COLUMN roster_automation_rules.event_offset_days IS 'Signed days from each target roster event start. NULL preserves legacy fixed-date rules.'; + +-- +goose Down +ALTER TABLE roster_automation_rules DROP COLUMN event_offset_days; diff --git a/database/timescale/029_roster_default_capacity.sql b/database/timescale/029_roster_default_capacity.sql new file mode 100644 index 0000000..ac93919 --- /dev/null +++ b/database/timescale/029_roster_default_capacity.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE rosters ALTER COLUMN max_signups SET DEFAULT 50; + +-- +goose Down +ALTER TABLE rosters ALTER COLUMN max_signups DROP DEFAULT; diff --git a/database/timescale/030_legend_daily_metadata.sql b/database/timescale/030_legend_daily_metadata.sql new file mode 100644 index 0000000..0545553 --- /dev/null +++ b/database/timescale/030_legend_daily_metadata.sql @@ -0,0 +1,94 @@ +-- +goose Up +-- Keep top_200 for existing consumers while adding the product-facing top 100. +ALTER TABLE public.army_family_daily_stats + DROP CONSTRAINT army_family_daily_stats_cohort_check, + ADD CONSTRAINT army_family_daily_stats_cohort_check + CHECK (cohort IN ('legend_i','top_1000','top_200','top_100')); + +ALTER TABLE public.legend_daily_stats + DROP CONSTRAINT legend_daily_stats_cohort_check, + ADD CONSTRAINT legend_daily_stats_cohort_check + CHECK (cohort IN ('legend_i','top_1000','top_200','top_100')); + +-- +goose StatementBegin +CREATE FUNCTION public.equipment_pair_usage_triples_within_attack_count(value jsonb, attack_limit bigint) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; + previous_hero integer := -1; + previous_first integer := -1; + previous_second integer := -1; + hero_value integer; + first_value integer; + second_value integer; + uses_value bigint; + triples_value bigint; +BEGIN + IF attack_limit < 0 OR jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP + IF jsonb_typeof(entry) <> 'object' OR (SELECT count(*) FROM jsonb_object_keys(entry)) <> 4 + OR NOT (entry ? 'heroId' AND entry ? 'equipmentIds' AND entry ? 'uses' AND entry ? 'triples') + OR jsonb_typeof(entry->'heroId') <> 'number' OR jsonb_typeof(entry->'equipmentIds') <> 'array' + OR jsonb_array_length(entry->'equipmentIds') <> 2 + OR jsonb_typeof(entry->'equipmentIds'->0) <> 'number' OR jsonb_typeof(entry->'equipmentIds'->1) <> 'number' + OR jsonb_typeof(entry->'uses') <> 'number' OR jsonb_typeof(entry->'triples') <> 'number' + OR entry->>'heroId' !~ '^[0-9]+$' OR entry->'equipmentIds'->>0 !~ '^[0-9]+$' + OR entry->'equipmentIds'->>1 !~ '^[0-9]+$' OR entry->>'uses' !~ '^[0-9]+$' + OR entry->>'triples' !~ '^[0-9]+$' + OR (entry->>'heroId')::numeric > 2147483647 + OR (entry->'equipmentIds'->>0)::numeric > 2147483647 + OR (entry->'equipmentIds'->>1)::numeric > 2147483647 + OR (entry->>'uses')::numeric > 9223372036854775807 + OR (entry->>'triples')::numeric > 9223372036854775807 THEN RETURN false; END IF; + hero_value := (entry->>'heroId')::integer; + first_value := (entry->'equipmentIds'->>0)::integer; + second_value := (entry->'equipmentIds'->>1)::integer; + uses_value := (entry->>'uses')::bigint; + triples_value := (entry->>'triples')::bigint; + IF first_value >= second_value OR triples_value > uses_value OR uses_value > attack_limit + OR hero_value < previous_hero + OR (hero_value = previous_hero AND first_value < previous_first) + OR (hero_value = previous_hero AND first_value = previous_first AND second_value <= previous_second) + THEN RETURN false; END IF; + previous_hero := hero_value; + previous_first := first_value; + previous_second := second_value; + END LOOP; + RETURN true; +EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; +END +$$; +-- +goose StatementEnd + +ALTER TABLE public.legend_daily_stats + ADD COLUMN troop_stats jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN spell_stats jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN siege_stats jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN equipment_pair_stats jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD CONSTRAINT legend_daily_stats_troops_check + CHECK (public.item_usage_triples_within_attack_count(troop_stats,attack_count)), + ADD CONSTRAINT legend_daily_stats_spells_check + CHECK (public.item_usage_triples_within_attack_count(spell_stats,attack_count)), + ADD CONSTRAINT legend_daily_stats_siege_check + CHECK (public.item_usage_triples_within_attack_count(siege_stats,attack_count)), + ADD CONSTRAINT legend_daily_stats_equipment_pairs_check + CHECK (public.equipment_pair_usage_triples_within_attack_count(equipment_pair_stats,attack_count)); + +COMMENT ON COLUMN public.legend_daily_stats.troop_stats IS + 'Per-attack presence counts for main-army troops; quantities do not multiply uses.'; +COMMENT ON COLUMN public.legend_daily_stats.spell_stats IS + 'Per-attack presence counts for regular and Clan Castle spells combined by ID.'; +COMMENT ON COLUMN public.legend_daily_stats.siege_stats IS + 'Per-attack presence counts for the canonical selected siege machine.'; +COMMENT ON COLUMN public.legend_daily_stats.equipment_pair_stats IS + 'Per-attack counts for each hero and its sorted two-equipment loadout.'; + +-- +goose Down +-- Daily top-100 and item-combination history cannot be reconstructed after removal. +-- +goose StatementBegin +DO $$ +BEGIN + RAISE EXCEPTION 'migration 030 is irreversible: Legend daily metadata may contain retained history'; +END +$$; +-- +goose StatementEnd diff --git a/database/timescale/031_legend_daily_pet_combos.sql b/database/timescale/031_legend_daily_pet_combos.sql new file mode 100644 index 0000000..c9dcaa4 --- /dev/null +++ b/database/timescale/031_legend_daily_pet_combos.sql @@ -0,0 +1,52 @@ +-- +goose Up +-- Pet combos were added after migration 030 had already been exercised locally, +-- so they remain a forward-only schema addition. +-- +goose StatementBegin +CREATE FUNCTION public.pet_combo_usage_triples_within_attack_count(value jsonb, attack_limit bigint) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; + pet_id jsonb; + previous_pet integer; + uses_value bigint; + triples_value bigint; +BEGIN + IF attack_limit < 0 OR jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP + IF jsonb_typeof(entry) <> 'object' OR (SELECT count(*) FROM jsonb_object_keys(entry)) <> 3 + OR NOT (entry ? 'petIds' AND entry ? 'uses' AND entry ? 'triples') + OR jsonb_typeof(entry->'petIds') <> 'array' OR jsonb_array_length(entry->'petIds') = 0 + OR jsonb_typeof(entry->'uses') <> 'number' OR jsonb_typeof(entry->'triples') <> 'number' + OR entry->>'uses' !~ '^[0-9]+$' OR entry->>'triples' !~ '^[0-9]+$' + OR (entry->>'uses')::numeric > 9223372036854775807 + OR (entry->>'triples')::numeric > 9223372036854775807 THEN RETURN false; END IF; + previous_pet := -1; + FOR pet_id IN SELECT element FROM jsonb_array_elements(entry->'petIds') WITH ORDINALITY AS item(element, position) ORDER BY position LOOP + IF jsonb_typeof(pet_id) <> 'number' OR pet_id #>> '{}' !~ '^[0-9]+$' + OR (pet_id #>> '{}')::numeric > 2147483647 + OR (pet_id #>> '{}')::integer <= previous_pet THEN RETURN false; END IF; + previous_pet := (pet_id #>> '{}')::integer; + END LOOP; + uses_value := (entry->>'uses')::bigint; + triples_value := (entry->>'triples')::bigint; + IF triples_value > uses_value OR uses_value > attack_limit THEN RETURN false; END IF; + END LOOP; + RETURN true; +EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; +END +$$; +-- +goose StatementEnd + +ALTER TABLE public.legend_daily_stats + ADD COLUMN pet_combo_stats jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD CONSTRAINT legend_daily_stats_pet_combos_check + CHECK (public.pet_combo_usage_triples_within_attack_count(pet_combo_stats,attack_count)); + +COMMENT ON COLUMN public.legend_daily_stats.pet_combo_stats IS + 'Per-attack counts for each non-empty sorted unique whole set of assigned pets.'; + +-- +goose Down +-- Pet-combo daily history cannot be reconstructed after removal. +-- +goose StatementBegin +DO $$ BEGIN RAISE EXCEPTION 'migration 031 is irreversible: Legend pet-combo history may exist'; END $$; +-- +goose StatementEnd diff --git a/database/timescale/032_remove_server_link_token_policy.sql b/database/timescale/032_remove_server_link_token_policy.sql new file mode 100644 index 0000000..bb6eef5 --- /dev/null +++ b/database/timescale/032_remove_server_link_token_policy.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Every new account link now verifies an in-game API token, independent of server. +ALTER TABLE public.servers DROP COLUMN require_api_token_when_linking; + +-- +goose Down +-- The removed per-server policy cannot be restored from retained data. +-- +goose StatementBegin +DO $$ BEGIN RAISE EXCEPTION 'migration 032 is irreversible: server link token policies were removed'; END $$; +-- +goose StatementEnd diff --git a/database/timescale/033_cwl_participation.sql b/database/timescale/033_cwl_participation.sql new file mode 100644 index 0000000..dcc0ded --- /dev/null +++ b/database/timescale/033_cwl_participation.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- Manually rebuilt CWL participation buckets; raw groups and wars remain canonical. +CREATE TABLE public.cwl_participation ( + season text NOT NULL, + cwl_league_id integer NOT NULL, + war_size smallint NOT NULL, + group_count bigint NOT NULL, + clan_count bigint NOT NULL, + registered_player_count bigint NOT NULL, + townhall_counts jsonb NOT NULL, + same_th_hitrates jsonb, + finalized_wars bigint NOT NULL, + archived_wars bigint NOT NULL, + refreshed_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (season, cwl_league_id, war_size), + CONSTRAINT cwl_participation_season_check CHECK (season ~ '^[0-9]{4}-(0[1-9]|1[0-2])$'), + CONSTRAINT cwl_participation_league_check CHECK (cwl_league_id > 48000000), + CONSTRAINT cwl_participation_size_check CHECK (war_size BETWEEN 1 AND 50), + CONSTRAINT cwl_participation_counts_check CHECK ( + group_count >= 0 AND clan_count >= 0 AND registered_player_count >= 0 + AND finalized_wars >= 0 AND archived_wars >= 0 AND archived_wars <= finalized_wars + ), + CONSTRAINT cwl_participation_townhall_check CHECK (jsonb_typeof(townhall_counts) = 'array'), + CONSTRAINT cwl_participation_hitrates_check CHECK ( + same_th_hitrates IS NULL OR jsonb_typeof(same_th_hitrates) = 'array' + ) +); + +-- +goose Down +DROP TABLE public.cwl_participation; diff --git a/database/timescale/034_daily_army_setups.sql b/database/timescale/034_daily_army_setups.sql new file mode 100644 index 0000000..b41a70f --- /dev/null +++ b/database/timescale/034_daily_army_setups.sql @@ -0,0 +1,44 @@ +-- +goose Up +-- Daily troop-overlap observations. Identity comes from core troops and setup +-- conditions, never a display name, row order or changing representative. +CREATE TABLE public.army_setup_daily_stats ( + day date NOT NULL, + league_tier_id integer NOT NULL CHECK (league_tier_id > 0), + rank_limit integer CHECK (rank_limit IN (200,1000)), + group_key text NOT NULL CHECK (length(group_key) BETWEEN 1 AND 256), + variant_key text NOT NULL DEFAULT '' CHECK (length(variant_key) <= 1024), + core_troops integer[] NOT NULL CHECK (cardinality(core_troops) > 0), + conditions jsonb NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(conditions) = 'array'), + representative_share_code text NOT NULL CHECK (length(representative_share_code) > 0), + attack_count bigint NOT NULL CHECK (attack_count > 0), + zero_star_count bigint NOT NULL CHECK (zero_star_count >= 0), + one_star_count bigint NOT NULL CHECK (one_star_count >= 0), + two_star_count bigint NOT NULL CHECK (two_star_count >= 0), + three_star_count bigint NOT NULL CHECK (three_star_count >= 0), + destruction_percentage_sum bigint NOT NULL CHECK (destruction_percentage_sum >= 0), + evidence jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(evidence) = 'object'), + calculated_at timestamptz NOT NULL DEFAULT now(), + CHECK (zero_star_count + one_star_count + two_star_count + three_star_count = attack_count), + CHECK (destruction_percentage_sum <= attack_count * 100), + CHECK ((variant_key = '' AND conditions = '[]'::jsonb) OR (variant_key <> '' AND jsonb_array_length(conditions) > 0)), + UNIQUE NULLS NOT DISTINCT (day,league_tier_id,rank_limit,group_key,variant_key) +); +CREATE INDEX army_setup_daily_identity ON public.army_setup_daily_stats(group_key,variant_key,day); +ALTER TABLE public.legend_daily_stats + ADD COLUMN army_analysis_completed_at timestamptz, + ADD COLUMN classified_army_attacks bigint, + ADD CONSTRAINT legend_daily_classified_attacks_check CHECK ( + classified_army_attacks BETWEEN 0 AND attack_count + AND ((army_analysis_completed_at IS NULL) = (classified_army_attacks IS NULL)) + ); +COMMENT ON COLUMN public.army_setup_daily_stats.variant_key IS + 'Empty means independent troop-group overview. Setup rows may overlap and must not be summed to produce overview counts.'; +COMMENT ON COLUMN public.legend_daily_stats.army_analysis_completed_at IS + 'Set atomically with setup observations. A missing setup row is not a measured zero; unsupported patterns are not persisted.'; + +-- +goose Down +-- +goose StatementBegin +DO $$ BEGIN + RAISE EXCEPTION 'migration 034 is irreversible: daily army setup history is retained'; +END $$; +-- +goose StatementEnd diff --git a/database/timescale/035_remove_army_analysis_timestamp.sql b/database/timescale/035_remove_army_analysis_timestamp.sql new file mode 100644 index 0000000..1353067 --- /dev/null +++ b/database/timescale/035_remove_army_analysis_timestamp.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- A non-null classified count is the atomic readiness marker for a finalized +-- daily army analysis, including days with zero classified attacks. +ALTER TABLE public.legend_daily_stats + DROP CONSTRAINT legend_daily_classified_attacks_check, + DROP COLUMN army_analysis_completed_at, + ADD CONSTRAINT legend_daily_classified_attacks_check + CHECK (classified_army_attacks BETWEEN 0 AND attack_count); +COMMENT ON COLUMN public.legend_daily_stats.classified_army_attacks IS + 'Non-null after daily army analysis commits atomically with its setup observations; zero is a completed empty classification.'; + +-- +goose Down +-- +goose StatementBegin +DO $$ BEGIN + RAISE EXCEPTION 'migration 035 is irreversible: army analysis completion timestamps are intentionally removed'; +END $$; +-- +goose StatementEnd diff --git a/database/timescale/036_army_setup_siege_usage.sql b/database/timescale/036_army_setup_siege_usage.sql new file mode 100644 index 0000000..94e4a6e --- /dev/null +++ b/database/timescale/036_army_setup_siege_usage.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Per-observation counts, measured from normalized CC siege on each attack. +-- Entries need not sum to attack_count because some codes have no siege. +ALTER TABLE public.army_setup_daily_stats + ADD COLUMN siege_usage jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(siege_usage) = 'array'); +COMMENT ON COLUMN public.army_setup_daily_stats.siege_usage IS + 'Observed normalized Clan Castle siege counts [{id,attacks}] for this day, cohort and group or variant; not inferred from the representative code.'; + +-- +goose Down +ALTER TABLE public.army_setup_daily_stats DROP COLUMN siege_usage; diff --git a/docs/worker-api-schema.md b/docs/worker-api-schema.md index d7eb428..ce8ed29 100644 --- a/docs/worker-api-schema.md +++ b/docs/worker-api-schema.md @@ -16,8 +16,8 @@ channels, users, members, roles, and application_emojis (discord_cache schema). Existing data changes: basic_clan.capital_gold_total plus global/location ranks; app_update_channels.rollback_target_version; billing_subscriptions. -initial_assignment_applied (true for existing rows, false for new rows); servers. -require_api_token_when_linking (false for existing and new servers). +initial_assignment_applied (true for existing rows, false for new rows). Migration +032 removes the retired per-server token policy; all new links require a valid token. ## Ticket configuration decision diff --git a/scripts/retained-api-profile.sh b/scripts/retained-api-profile.sh index 5893fb4..7f8a345 100755 --- a/scripts/retained-api-profile.sh +++ b/scripts/retained-api-profile.sh @@ -1,5 +1,5 @@ # Canonical migration inventory for retained API tests and local development. -# This is the same contiguous 001-022 sequence used for production upgrades. +# This is the same contiguous 001-036 sequence used for production upgrades. fixture_image='timescale/timescaledb:2.29.2-pg18@sha256:9508616d5b941ed931198504c5db3fb47e8f53f790732ea1e889591f1062057c' fixture_sources=( 001_initial_stats.sql @@ -24,4 +24,18 @@ fixture_sources=( 020_player_leaderboard_snapshots.sql 021_inline_base_images_votes.sql 022_personal_army_library.sql + 023_roster_signup_scope.sql + 024_roster_admission_settings.sql + 025_roster_discord_publications.sql + 026_roster_publication_webhooks.sql + 027_roster_embed_color.sql + 028_roster_automation_event_offsets.sql + 029_roster_default_capacity.sql + 030_legend_daily_metadata.sql + 031_legend_daily_pet_combos.sql + 032_remove_server_link_token_policy.sql + 033_cwl_participation.sql + 034_daily_army_setups.sql + 035_remove_army_analysis_timestamp.sql + 036_army_setup_siege_usage.sql ) diff --git a/scripts/with-test-timescale.test.mjs b/scripts/with-test-timescale.test.mjs index e64839f..80608aa 100644 --- a/scripts/with-test-timescale.test.mjs +++ b/scripts/with-test-timescale.test.mjs @@ -86,6 +86,20 @@ test('uses only retained authoritative migrations and its own disposable contain '020_player_leaderboard_snapshots.sql', '021_inline_base_images_votes.sql', '022_personal_army_library.sql', + '023_roster_signup_scope.sql', + '024_roster_admission_settings.sql', + '025_roster_discord_publications.sql', + '026_roster_publication_webhooks.sql', + '027_roster_embed_color.sql', + '028_roster_automation_event_offsets.sql', + '029_roster_default_capacity.sql', + '030_legend_daily_metadata.sql', + '031_legend_daily_pet_combos.sql', + '032_remove_server_link_token_policy.sql', + '033_cwl_participation.sql', + '034_daily_army_setups.sql', + '035_remove_army_analysis_timestamp.sql', + '036_army_setup_siege_usage.sql', ]); } assert.equal(migrations[1].args.at(-1), 'up'); From 935d26ddd9a420fecdf8583d8dd7237019ad3e75 Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 25 Sep 2026 09:56:53 -0500 Subject: [PATCH 03/10] fix(database): constrain daily pet and siege counts --- .../daily_analytics_constraints_test.go | 45 +++++++++++++++++++ .../timescale/031_legend_daily_pet_combos.sql | 10 ++++- .../timescale/036_army_setup_siege_usage.sql | 32 ++++++++++++- 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 database/migrations/daily_analytics_constraints_test.go diff --git a/database/migrations/daily_analytics_constraints_test.go b/database/migrations/daily_analytics_constraints_test.go new file mode 100644 index 0000000..22bd268 --- /dev/null +++ b/database/migrations/daily_analytics_constraints_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5" +) + +func TestDailyAnalyticsUsageConstraints(t *testing.T) { + if os.Getenv("CLASHKING_DISPOSABLE_TIMESCALE") != "1" || os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("requires disposable authoritative Goose schema") + } + conn, err := pgx.Connect(t.Context(), os.Getenv("TEST_DATABASE_URL")) + if err != nil { + t.Fatal(err) + } + defer conn.Close(context.Background()) + for _, tc := range []struct { + name, function, value string + want bool + }{ + {"valid pet combinations", "pet_combo_usage_triples_within_attack_count", `[{"petIds":[1,2],"uses":2,"triples":1},{"petIds":[2,3],"uses":3,"triples":2}]`, true}, + {"duplicate pet combination", "pet_combo_usage_triples_within_attack_count", `[{"petIds":[1,2],"uses":2,"triples":1},{"petIds":[1,2],"uses":2,"triples":1}]`, false}, + {"unordered pet combinations", "pet_combo_usage_triples_within_attack_count", `[{"petIds":[2,3],"uses":1,"triples":0},{"petIds":[1,2],"uses":1,"triples":0}]`, false}, + {"overcounted pet combinations", "pet_combo_usage_triples_within_attack_count", `[{"petIds":[1],"uses":3,"triples":0},{"petIds":[2],"uses":3,"triples":0}]`, false}, + {"valid siege usage", "army_setup_siege_usage_within_attack_count", `[{"id":1,"attacks":2},{"id":2,"attacks":3}]`, true}, + {"malformed siege usage", "army_setup_siege_usage_within_attack_count", `[{"id":1,"attacks":"2"}]`, false}, + {"duplicate siege ID", "army_setup_siege_usage_within_attack_count", `[{"id":1,"attacks":2},{"id":1,"attacks":1}]`, false}, + {"unordered siege IDs", "army_setup_siege_usage_within_attack_count", `[{"id":2,"attacks":1},{"id":1,"attacks":1}]`, false}, + {"overcounted siege usage", "army_setup_siege_usage_within_attack_count", `[{"id":1,"attacks":3},{"id":2,"attacks":3}]`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var got bool + query := "SELECT public." + tc.function + "($1::jsonb, 5::bigint)" + if err := conn.QueryRow(t.Context(), query, tc.value).Scan(&got); err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("validator returned %v, want %v", got, tc.want) + } + }) + } +} diff --git a/database/timescale/031_legend_daily_pet_combos.sql b/database/timescale/031_legend_daily_pet_combos.sql index c9dcaa4..4abfe1e 100644 --- a/database/timescale/031_legend_daily_pet_combos.sql +++ b/database/timescale/031_legend_daily_pet_combos.sql @@ -8,8 +8,11 @@ DECLARE entry jsonb; pet_id jsonb; previous_pet integer; + current_combo integer[]; + previous_combo integer[]; uses_value bigint; triples_value bigint; + total_uses bigint := 0; BEGIN IF attack_limit < 0 OR jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP @@ -21,15 +24,20 @@ BEGIN OR (entry->>'uses')::numeric > 9223372036854775807 OR (entry->>'triples')::numeric > 9223372036854775807 THEN RETURN false; END IF; previous_pet := -1; + current_combo := '{}'::integer[]; FOR pet_id IN SELECT element FROM jsonb_array_elements(entry->'petIds') WITH ORDINALITY AS item(element, position) ORDER BY position LOOP IF jsonb_typeof(pet_id) <> 'number' OR pet_id #>> '{}' !~ '^[0-9]+$' OR (pet_id #>> '{}')::numeric > 2147483647 OR (pet_id #>> '{}')::integer <= previous_pet THEN RETURN false; END IF; previous_pet := (pet_id #>> '{}')::integer; + current_combo := array_append(current_combo, previous_pet); END LOOP; uses_value := (entry->>'uses')::bigint; triples_value := (entry->>'triples')::bigint; - IF triples_value > uses_value OR uses_value > attack_limit THEN RETURN false; END IF; + IF (previous_combo IS NOT NULL AND current_combo <= previous_combo) + OR triples_value > uses_value OR uses_value > attack_limit - total_uses THEN RETURN false; END IF; + previous_combo := current_combo; + total_uses := total_uses + uses_value; END LOOP; RETURN true; EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; diff --git a/database/timescale/036_army_setup_siege_usage.sql b/database/timescale/036_army_setup_siege_usage.sql index 94e4a6e..7ee0dfd 100644 --- a/database/timescale/036_army_setup_siege_usage.sql +++ b/database/timescale/036_army_setup_siege_usage.sql @@ -1,11 +1,41 @@ -- +goose Up -- Per-observation counts, measured from normalized CC siege on each attack. -- Entries need not sum to attack_count because some codes have no siege. +-- +goose StatementBegin +CREATE FUNCTION public.army_setup_siege_usage_within_attack_count(value jsonb, attack_limit bigint) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; + siege_id integer; + previous_id integer := -1; + attacks_value bigint; + total_attacks bigint := 0; +BEGIN + IF attack_limit < 0 OR jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP + IF jsonb_typeof(entry) <> 'object' OR (SELECT count(*) FROM jsonb_object_keys(entry)) <> 2 + OR NOT (entry ? 'id' AND entry ? 'attacks') + OR jsonb_typeof(entry->'id') <> 'number' OR jsonb_typeof(entry->'attacks') <> 'number' + OR entry->>'id' !~ '^[0-9]+$' OR entry->>'attacks' !~ '^[0-9]+$' + OR (entry->>'id')::numeric > 2147483647 + OR (entry->>'attacks')::numeric > 9223372036854775807 THEN RETURN false; END IF; + siege_id := (entry->>'id')::integer; + attacks_value := (entry->>'attacks')::bigint; + IF siege_id <= previous_id OR siege_id = 0 OR attacks_value > attack_limit - total_attacks THEN RETURN false; END IF; + previous_id := siege_id; + total_attacks := total_attacks + attacks_value; + END LOOP; + RETURN true; +EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; +END +$$; +-- +goose StatementEnd ALTER TABLE public.army_setup_daily_stats ADD COLUMN siege_usage jsonb NOT NULL DEFAULT '[]'::jsonb - CHECK (jsonb_typeof(siege_usage) = 'array'); + CHECK (public.army_setup_siege_usage_within_attack_count(siege_usage,attack_count)); COMMENT ON COLUMN public.army_setup_daily_stats.siege_usage IS 'Observed normalized Clan Castle siege counts [{id,attacks}] for this day, cohort and group or variant; not inferred from the representative code.'; -- +goose Down ALTER TABLE public.army_setup_daily_stats DROP COLUMN siege_usage; +DROP FUNCTION public.army_setup_siege_usage_within_attack_count(jsonb,bigint); From ff23f25e7443d38a444218a74b39c7a2960daa4d Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 25 Sep 2026 10:05:39 -0500 Subject: [PATCH 04/10] feat(database): reserve roster publication nonces --- .../migrations/war_archive_schema_test.go | 2 + .../timescale/031_legend_daily_pet_combos.sql | 10 +-- .../timescale/036_army_setup_siege_usage.sql | 32 +------ .../037_roster_publication_requests.sql | 18 ++++ .../038_daily_analytics_count_constraints.sql | 90 +++++++++++++++++++ scripts/retained-api-profile.sh | 4 +- scripts/with-test-timescale.test.mjs | 2 + 7 files changed, 117 insertions(+), 41 deletions(-) create mode 100644 database/timescale/037_roster_publication_requests.sql create mode 100644 database/timescale/038_daily_analytics_count_constraints.sql diff --git a/database/migrations/war_archive_schema_test.go b/database/migrations/war_archive_schema_test.go index bf7aeeb..2b7a3ee 100644 --- a/database/migrations/war_archive_schema_test.go +++ b/database/migrations/war_archive_schema_test.go @@ -49,6 +49,8 @@ func TestTimescaleMigrationsAreExplicitlyNumbered(t *testing.T) { "034_daily_army_setups.sql", "035_remove_army_analysis_timestamp.sql", "036_army_setup_siege_usage.sql", + "037_roster_publication_requests.sql", + "038_daily_analytics_count_constraints.sql", } if len(files) != len(want) { t.Fatalf("Timescale migrations = %v, want %v", files, want) diff --git a/database/timescale/031_legend_daily_pet_combos.sql b/database/timescale/031_legend_daily_pet_combos.sql index 4abfe1e..c9dcaa4 100644 --- a/database/timescale/031_legend_daily_pet_combos.sql +++ b/database/timescale/031_legend_daily_pet_combos.sql @@ -8,11 +8,8 @@ DECLARE entry jsonb; pet_id jsonb; previous_pet integer; - current_combo integer[]; - previous_combo integer[]; uses_value bigint; triples_value bigint; - total_uses bigint := 0; BEGIN IF attack_limit < 0 OR jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP @@ -24,20 +21,15 @@ BEGIN OR (entry->>'uses')::numeric > 9223372036854775807 OR (entry->>'triples')::numeric > 9223372036854775807 THEN RETURN false; END IF; previous_pet := -1; - current_combo := '{}'::integer[]; FOR pet_id IN SELECT element FROM jsonb_array_elements(entry->'petIds') WITH ORDINALITY AS item(element, position) ORDER BY position LOOP IF jsonb_typeof(pet_id) <> 'number' OR pet_id #>> '{}' !~ '^[0-9]+$' OR (pet_id #>> '{}')::numeric > 2147483647 OR (pet_id #>> '{}')::integer <= previous_pet THEN RETURN false; END IF; previous_pet := (pet_id #>> '{}')::integer; - current_combo := array_append(current_combo, previous_pet); END LOOP; uses_value := (entry->>'uses')::bigint; triples_value := (entry->>'triples')::bigint; - IF (previous_combo IS NOT NULL AND current_combo <= previous_combo) - OR triples_value > uses_value OR uses_value > attack_limit - total_uses THEN RETURN false; END IF; - previous_combo := current_combo; - total_uses := total_uses + uses_value; + IF triples_value > uses_value OR uses_value > attack_limit THEN RETURN false; END IF; END LOOP; RETURN true; EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; diff --git a/database/timescale/036_army_setup_siege_usage.sql b/database/timescale/036_army_setup_siege_usage.sql index 7ee0dfd..94e4a6e 100644 --- a/database/timescale/036_army_setup_siege_usage.sql +++ b/database/timescale/036_army_setup_siege_usage.sql @@ -1,41 +1,11 @@ -- +goose Up -- Per-observation counts, measured from normalized CC siege on each attack. -- Entries need not sum to attack_count because some codes have no siege. --- +goose StatementBegin -CREATE FUNCTION public.army_setup_siege_usage_within_attack_count(value jsonb, attack_limit bigint) -RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ -DECLARE - entry jsonb; - siege_id integer; - previous_id integer := -1; - attacks_value bigint; - total_attacks bigint := 0; -BEGIN - IF attack_limit < 0 OR jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; - FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP - IF jsonb_typeof(entry) <> 'object' OR (SELECT count(*) FROM jsonb_object_keys(entry)) <> 2 - OR NOT (entry ? 'id' AND entry ? 'attacks') - OR jsonb_typeof(entry->'id') <> 'number' OR jsonb_typeof(entry->'attacks') <> 'number' - OR entry->>'id' !~ '^[0-9]+$' OR entry->>'attacks' !~ '^[0-9]+$' - OR (entry->>'id')::numeric > 2147483647 - OR (entry->>'attacks')::numeric > 9223372036854775807 THEN RETURN false; END IF; - siege_id := (entry->>'id')::integer; - attacks_value := (entry->>'attacks')::bigint; - IF siege_id <= previous_id OR siege_id = 0 OR attacks_value > attack_limit - total_attacks THEN RETURN false; END IF; - previous_id := siege_id; - total_attacks := total_attacks + attacks_value; - END LOOP; - RETURN true; -EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; -END -$$; --- +goose StatementEnd ALTER TABLE public.army_setup_daily_stats ADD COLUMN siege_usage jsonb NOT NULL DEFAULT '[]'::jsonb - CHECK (public.army_setup_siege_usage_within_attack_count(siege_usage,attack_count)); + CHECK (jsonb_typeof(siege_usage) = 'array'); COMMENT ON COLUMN public.army_setup_daily_stats.siege_usage IS 'Observed normalized Clan Castle siege counts [{id,attacks}] for this day, cohort and group or variant; not inferred from the representative code.'; -- +goose Down ALTER TABLE public.army_setup_daily_stats DROP COLUMN siege_usage; -DROP FUNCTION public.army_setup_siege_usage_within_attack_count(jsonb,bigint); diff --git a/database/timescale/037_roster_publication_requests.sql b/database/timescale/037_roster_publication_requests.sql new file mode 100644 index 0000000..9b93a82 --- /dev/null +++ b/database/timescale/037_roster_publication_requests.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Reserve each client nonce before sending to Discord. A missing message_id is +-- deliberately uncertain after a worker interruption and must not be resent. +CREATE TABLE public.roster_publication_requests ( + roster_id uuid NOT NULL REFERENCES public.rosters(id) ON DELETE CASCADE, + nonce text NOT NULL CHECK (nonce ~ '^[A-Za-z0-9_-]{1,25}$'), + payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'), + message_id text CHECK (message_id ~ '^[0-9]{1,20}$'), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (roster_id, nonce) +); + +-- +goose Down +-- Request history cannot be discarded safely while clients may retry. +-- +goose StatementBegin +DO $$ BEGIN RAISE EXCEPTION 'migration 037 is irreversible: publication idempotency history may exist'; END $$; +-- +goose StatementEnd diff --git a/database/timescale/038_daily_analytics_count_constraints.sql b/database/timescale/038_daily_analytics_count_constraints.sql new file mode 100644 index 0000000..e0b6598 --- /dev/null +++ b/database/timescale/038_daily_analytics_count_constraints.sql @@ -0,0 +1,90 @@ +-- +goose Up +-- Preserve applied 031 and 036 byte-for-byte; tighten their checks forward. +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION public.pet_combo_usage_triples_within_attack_count(value jsonb, attack_limit bigint) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; + pet_id jsonb; + previous_pet integer; + current_combo integer[]; + previous_combo integer[]; + uses_value bigint; + triples_value bigint; + total_uses bigint := 0; +BEGIN + IF attack_limit < 0 OR jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP + IF jsonb_typeof(entry) <> 'object' OR (SELECT count(*) FROM jsonb_object_keys(entry)) <> 3 + OR NOT (entry ? 'petIds' AND entry ? 'uses' AND entry ? 'triples') + OR jsonb_typeof(entry->'petIds') <> 'array' OR jsonb_array_length(entry->'petIds') = 0 + OR jsonb_typeof(entry->'uses') <> 'number' OR jsonb_typeof(entry->'triples') <> 'number' + OR entry->>'uses' !~ '^[0-9]+$' OR entry->>'triples' !~ '^[0-9]+$' + OR (entry->>'uses')::numeric > 9223372036854775807 + OR (entry->>'triples')::numeric > 9223372036854775807 THEN RETURN false; END IF; + previous_pet := -1; + current_combo := '{}'::integer[]; + FOR pet_id IN SELECT element FROM jsonb_array_elements(entry->'petIds') WITH ORDINALITY AS item(element, position) ORDER BY position LOOP + IF jsonb_typeof(pet_id) <> 'number' OR pet_id #>> '{}' !~ '^[0-9]+$' + OR (pet_id #>> '{}')::numeric > 2147483647 + OR (pet_id #>> '{}')::integer <= previous_pet THEN RETURN false; END IF; + previous_pet := (pet_id #>> '{}')::integer; + current_combo := array_append(current_combo, previous_pet); + END LOOP; + uses_value := (entry->>'uses')::bigint; + triples_value := (entry->>'triples')::bigint; + IF (previous_combo IS NOT NULL AND current_combo <= previous_combo) + OR triples_value > uses_value OR uses_value > attack_limit - total_uses THEN RETURN false; END IF; + previous_combo := current_combo; + total_uses := total_uses + uses_value; + END LOOP; + RETURN true; +EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; +END +$$; +-- +goose StatementEnd + +ALTER TABLE public.legend_daily_stats + DROP CONSTRAINT legend_daily_stats_pet_combos_check, + ADD CONSTRAINT legend_daily_stats_pet_combos_check + CHECK (public.pet_combo_usage_triples_within_attack_count(pet_combo_stats,attack_count)); + +-- +goose StatementBegin +CREATE FUNCTION public.army_setup_siege_usage_within_attack_count(value jsonb, attack_limit bigint) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; + siege_id integer; + previous_id integer := -1; + attacks_value bigint; + total_attacks bigint := 0; +BEGIN + IF attack_limit < 0 OR jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP + IF jsonb_typeof(entry) <> 'object' OR (SELECT count(*) FROM jsonb_object_keys(entry)) <> 2 + OR NOT (entry ? 'id' AND entry ? 'attacks') + OR jsonb_typeof(entry->'id') <> 'number' OR jsonb_typeof(entry->'attacks') <> 'number' + OR entry->>'id' !~ '^[0-9]+$' OR entry->>'attacks' !~ '^[0-9]+$' + OR (entry->>'id')::numeric > 2147483647 + OR (entry->>'attacks')::numeric > 9223372036854775807 THEN RETURN false; END IF; + siege_id := (entry->>'id')::integer; + attacks_value := (entry->>'attacks')::bigint; + IF siege_id <= previous_id OR siege_id = 0 OR attacks_value > attack_limit - total_attacks THEN RETURN false; END IF; + previous_id := siege_id; + total_attacks := total_attacks + attacks_value; + END LOOP; + RETURN true; +EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; +END +$$; +-- +goose StatementEnd + +ALTER TABLE public.army_setup_daily_stats + ADD CONSTRAINT army_setup_daily_siege_usage_count_check + CHECK (public.army_setup_siege_usage_within_attack_count(siege_usage,attack_count)); + +-- +goose Down +-- Reverting the stronger checks could admit impossible retained history. +-- +goose StatementBegin +DO $$ BEGIN RAISE EXCEPTION 'migration 038 is irreversible: daily analytics count constraints must be retained'; END $$; +-- +goose StatementEnd diff --git a/scripts/retained-api-profile.sh b/scripts/retained-api-profile.sh index 7f8a345..955706c 100755 --- a/scripts/retained-api-profile.sh +++ b/scripts/retained-api-profile.sh @@ -1,5 +1,5 @@ # Canonical migration inventory for retained API tests and local development. -# This is the same contiguous 001-036 sequence used for production upgrades. +# This is the same contiguous 001-038 sequence used for production upgrades. fixture_image='timescale/timescaledb:2.29.2-pg18@sha256:9508616d5b941ed931198504c5db3fb47e8f53f790732ea1e889591f1062057c' fixture_sources=( 001_initial_stats.sql @@ -38,4 +38,6 @@ fixture_sources=( 034_daily_army_setups.sql 035_remove_army_analysis_timestamp.sql 036_army_setup_siege_usage.sql + 037_roster_publication_requests.sql + 038_daily_analytics_count_constraints.sql ) diff --git a/scripts/with-test-timescale.test.mjs b/scripts/with-test-timescale.test.mjs index 80608aa..2dd38dc 100644 --- a/scripts/with-test-timescale.test.mjs +++ b/scripts/with-test-timescale.test.mjs @@ -100,6 +100,8 @@ test('uses only retained authoritative migrations and its own disposable contain '034_daily_army_setups.sql', '035_remove_army_analysis_timestamp.sql', '036_army_setup_siege_usage.sql', + '037_roster_publication_requests.sql', + '038_daily_analytics_count_constraints.sql', ]); } assert.equal(migrations[1].args.at(-1), 'up'); From 3e4c66bad5732d9481c252b39f9286c17aa1d30a Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 25 Sep 2026 10:16:53 -0500 Subject: [PATCH 05/10] fix(database): enforce exclusive legend daily usage --- .../daily_analytics_constraints_test.go | 4 + .../migrations/war_archive_schema_test.go | 1 + .../039_legend_daily_exclusive_usage.sql | 87 +++++++++++++++++++ scripts/retained-api-profile.sh | 3 +- scripts/with-test-timescale.test.mjs | 1 + 5 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 database/timescale/039_legend_daily_exclusive_usage.sql diff --git a/database/migrations/daily_analytics_constraints_test.go b/database/migrations/daily_analytics_constraints_test.go index 22bd268..1c8a5d0 100644 --- a/database/migrations/daily_analytics_constraints_test.go +++ b/database/migrations/daily_analytics_constraints_test.go @@ -30,6 +30,10 @@ func TestDailyAnalyticsUsageConstraints(t *testing.T) { {"duplicate siege ID", "army_setup_siege_usage_within_attack_count", `[{"id":1,"attacks":2},{"id":1,"attacks":1}]`, false}, {"unordered siege IDs", "army_setup_siege_usage_within_attack_count", `[{"id":2,"attacks":1},{"id":1,"attacks":1}]`, false}, {"overcounted siege usage", "army_setup_siege_usage_within_attack_count", `[{"id":1,"attacks":3},{"id":2,"attacks":3}]`, false}, + {"valid Legend siege usage", "legend_siege_usage_within_attack_count", `[{"id":1,"uses":2,"triples":1},{"id":2,"uses":3,"triples":0}]`, true}, + {"overcounted Legend siege usage", "legend_siege_usage_within_attack_count", `[{"id":1,"uses":3,"triples":0},{"id":2,"uses":3,"triples":0}]`, false}, + {"valid equipment pairs for multiple heroes", "equipment_pair_usage_triples_within_attack_count", `[{"heroId":1,"equipmentIds":[1,2],"uses":3,"triples":0},{"heroId":1,"equipmentIds":[1,3],"uses":2,"triples":0},{"heroId":2,"equipmentIds":[1,2],"uses":5,"triples":0}]`, true}, + {"overcounted equipment pairs for one hero", "equipment_pair_usage_triples_within_attack_count", `[{"heroId":1,"equipmentIds":[1,2],"uses":3,"triples":0},{"heroId":1,"equipmentIds":[1,3],"uses":3,"triples":0}]`, false}, } { t.Run(tc.name, func(t *testing.T) { var got bool diff --git a/database/migrations/war_archive_schema_test.go b/database/migrations/war_archive_schema_test.go index 2b7a3ee..e796823 100644 --- a/database/migrations/war_archive_schema_test.go +++ b/database/migrations/war_archive_schema_test.go @@ -51,6 +51,7 @@ func TestTimescaleMigrationsAreExplicitlyNumbered(t *testing.T) { "036_army_setup_siege_usage.sql", "037_roster_publication_requests.sql", "038_daily_analytics_count_constraints.sql", + "039_legend_daily_exclusive_usage.sql", } if len(files) != len(want) { t.Fatalf("Timescale migrations = %v, want %v", files, want) diff --git a/database/timescale/039_legend_daily_exclusive_usage.sql b/database/timescale/039_legend_daily_exclusive_usage.sql new file mode 100644 index 0000000..bba88c0 --- /dev/null +++ b/database/timescale/039_legend_daily_exclusive_usage.sql @@ -0,0 +1,87 @@ +-- +goose Up +-- Siege selection and each hero's equipment pair are exclusive per attack. +-- Preserve the previously applied 030 migration and tighten its checks forward. +-- +goose StatementBegin +CREATE FUNCTION public.legend_siege_usage_within_attack_count(value jsonb, attack_limit bigint) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; + total_uses bigint := 0; +BEGIN + IF attack_limit < 0 OR NOT public.item_usage_triples_valid(value) THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) item(element) LOOP + IF (entry->>'uses')::bigint > attack_limit - total_uses THEN RETURN false; END IF; + total_uses := total_uses + (entry->>'uses')::bigint; + END LOOP; + RETURN true; +END +$$; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION public.equipment_pair_usage_triples_within_attack_count(value jsonb, attack_limit bigint) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; + previous_hero integer := -1; + previous_first integer := -1; + previous_second integer := -1; + hero_value integer; + first_value integer; + second_value integer; + uses_value bigint; + triples_value bigint; + hero_uses bigint := 0; +BEGIN + IF attack_limit < 0 OR jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP + IF jsonb_typeof(entry) <> 'object' OR (SELECT count(*) FROM jsonb_object_keys(entry)) <> 4 + OR NOT (entry ? 'heroId' AND entry ? 'equipmentIds' AND entry ? 'uses' AND entry ? 'triples') + OR jsonb_typeof(entry->'heroId') <> 'number' OR jsonb_typeof(entry->'equipmentIds') <> 'array' + OR jsonb_array_length(entry->'equipmentIds') <> 2 + OR jsonb_typeof(entry->'equipmentIds'->0) <> 'number' OR jsonb_typeof(entry->'equipmentIds'->1) <> 'number' + OR jsonb_typeof(entry->'uses') <> 'number' OR jsonb_typeof(entry->'triples') <> 'number' + OR entry->>'heroId' !~ '^[0-9]+$' OR entry->'equipmentIds'->>0 !~ '^[0-9]+$' + OR entry->'equipmentIds'->>1 !~ '^[0-9]+$' OR entry->>'uses' !~ '^[0-9]+$' + OR entry->>'triples' !~ '^[0-9]+$' + OR (entry->>'heroId')::numeric > 2147483647 + OR (entry->'equipmentIds'->>0)::numeric > 2147483647 + OR (entry->'equipmentIds'->>1)::numeric > 2147483647 + OR (entry->>'uses')::numeric > 9223372036854775807 + OR (entry->>'triples')::numeric > 9223372036854775807 THEN RETURN false; END IF; + hero_value := (entry->>'heroId')::integer; + first_value := (entry->'equipmentIds'->>0)::integer; + second_value := (entry->'equipmentIds'->>1)::integer; + uses_value := (entry->>'uses')::bigint; + triples_value := (entry->>'triples')::bigint; + IF hero_value <> previous_hero THEN hero_uses := 0; END IF; + IF first_value >= second_value OR triples_value > uses_value + OR uses_value > attack_limit - hero_uses + OR hero_value < previous_hero + OR (hero_value = previous_hero AND first_value < previous_first) + OR (hero_value = previous_hero AND first_value = previous_first AND second_value <= previous_second) + THEN RETURN false; END IF; + hero_uses := hero_uses + uses_value; + previous_hero := hero_value; + previous_first := first_value; + previous_second := second_value; + END LOOP; + RETURN true; +EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; +END +$$; +-- +goose StatementEnd + +ALTER TABLE public.legend_daily_stats + DROP CONSTRAINT legend_daily_stats_siege_check, + ADD CONSTRAINT legend_daily_stats_siege_check + CHECK (public.legend_siege_usage_within_attack_count(siege_stats,attack_count)), + DROP CONSTRAINT legend_daily_stats_equipment_pairs_check, + ADD CONSTRAINT legend_daily_stats_equipment_pairs_check + CHECK (public.equipment_pair_usage_triples_within_attack_count(equipment_pair_stats,attack_count)); + +-- +goose Down +-- Loosening these checks could admit impossible retained history. +-- +goose StatementBegin +DO $$ BEGIN RAISE EXCEPTION 'migration 039 is irreversible: exclusive Legend usage constraints must be retained'; END $$; +-- +goose StatementEnd diff --git a/scripts/retained-api-profile.sh b/scripts/retained-api-profile.sh index 955706c..865308e 100755 --- a/scripts/retained-api-profile.sh +++ b/scripts/retained-api-profile.sh @@ -1,5 +1,5 @@ # Canonical migration inventory for retained API tests and local development. -# This is the same contiguous 001-038 sequence used for production upgrades. +# This is the same contiguous 001-039 sequence used for production upgrades. fixture_image='timescale/timescaledb:2.29.2-pg18@sha256:9508616d5b941ed931198504c5db3fb47e8f53f790732ea1e889591f1062057c' fixture_sources=( 001_initial_stats.sql @@ -40,4 +40,5 @@ fixture_sources=( 036_army_setup_siege_usage.sql 037_roster_publication_requests.sql 038_daily_analytics_count_constraints.sql + 039_legend_daily_exclusive_usage.sql ) diff --git a/scripts/with-test-timescale.test.mjs b/scripts/with-test-timescale.test.mjs index 2dd38dc..e736a0b 100644 --- a/scripts/with-test-timescale.test.mjs +++ b/scripts/with-test-timescale.test.mjs @@ -102,6 +102,7 @@ test('uses only retained authoritative migrations and its own disposable contain '036_army_setup_siege_usage.sql', '037_roster_publication_requests.sql', '038_daily_analytics_count_constraints.sql', + '039_legend_daily_exclusive_usage.sql', ]); } assert.equal(migrations[1].args.at(-1), 'up'); From 9f57a895b3282a8afadbeb3d57584023cd285a6c Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 25 Sep 2026 10:33:04 -0500 Subject: [PATCH 06/10] fix(database): bound exclusive legend triples --- .github/workflows/schema-upgrade.yml | 6 ++ .../daily_analytics_constraints_test.go | 21 +++++++ .../migrations/war_archive_schema_test.go | 1 + .../040_legend_exclusive_triple_totals.sql | 56 +++++++++++++++++++ scripts/retained-api-profile.sh | 3 +- scripts/with-test-timescale.test.mjs | 1 + 6 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 database/timescale/040_legend_exclusive_triple_totals.sql diff --git a/.github/workflows/schema-upgrade.yml b/.github/workflows/schema-upgrade.yml index daa7843..794a001 100644 --- a/.github/workflows/schema-upgrade.yml +++ b/.github/workflows/schema-upgrade.yml @@ -30,3 +30,9 @@ jobs: - name: Apply 018 then verify the populated personal-base upgrade working-directory: database run: bash ../scripts/with-test-timescale.sh --profile baseline-018 -- go test ./schema -run '^TestPersonalBaseStorageMigration$' -count=1 -v + - name: Apply 021 then verify the populated personal-army upgrade + working-directory: database + run: bash ../scripts/with-test-timescale.sh --profile baseline-021 -- go test ./schema -run '^TestPersonalArmyStorageMigration$' -count=1 -v + - name: Verify daily analytics constraints on the complete retained schema + working-directory: database + run: bash ../scripts/with-test-timescale.sh --profile retained-api -- go test ./migrations -run '^TestDailyAnalyticsUsageConstraints$' -count=1 -v diff --git a/database/migrations/daily_analytics_constraints_test.go b/database/migrations/daily_analytics_constraints_test.go index 1c8a5d0..0bbe72a 100644 --- a/database/migrations/daily_analytics_constraints_test.go +++ b/database/migrations/daily_analytics_constraints_test.go @@ -46,4 +46,25 @@ func TestDailyAnalyticsUsageConstraints(t *testing.T) { } }) } + for _, tc := range []struct { + name, value string + perHero bool + want bool + }{ + {"pet combos bounded by three stars", `[{"petIds":[1],"uses":1,"triples":1},{"petIds":[2],"uses":1,"triples":1}]`, false, true}, + {"pet combos exceed three stars", `[{"petIds":[1],"uses":3,"triples":3}]`, false, false}, + {"siege uses exceed three stars", `[{"id":1,"uses":1,"triples":1},{"id":2,"uses":1,"triples":2}]`, false, false}, + {"equipment pairs bounded per hero", `[{"heroId":1,"equipmentIds":[1,2],"uses":2,"triples":2},{"heroId":2,"equipmentIds":[1,2],"uses":2,"triples":2}]`, true, true}, + {"equipment pairs exceed three stars for hero", `[{"heroId":1,"equipmentIds":[1,2],"uses":2,"triples":2},{"heroId":1,"equipmentIds":[1,3],"uses":1,"triples":1}]`, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var got bool + if err := conn.QueryRow(t.Context(), "SELECT public.legend_exclusive_triples_within_star_count($1::jsonb, 2::bigint, $2::boolean)", tc.value, tc.perHero).Scan(&got); err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("validator returned %v, want %v", got, tc.want) + } + }) + } } diff --git a/database/migrations/war_archive_schema_test.go b/database/migrations/war_archive_schema_test.go index e796823..b5f9de1 100644 --- a/database/migrations/war_archive_schema_test.go +++ b/database/migrations/war_archive_schema_test.go @@ -52,6 +52,7 @@ func TestTimescaleMigrationsAreExplicitlyNumbered(t *testing.T) { "037_roster_publication_requests.sql", "038_daily_analytics_count_constraints.sql", "039_legend_daily_exclusive_usage.sql", + "040_legend_exclusive_triple_totals.sql", } if len(files) != len(want) { t.Fatalf("Timescale migrations = %v, want %v", files, want) diff --git a/database/timescale/040_legend_exclusive_triple_totals.sql b/database/timescale/040_legend_exclusive_triple_totals.sql new file mode 100644 index 0000000..5a6546d --- /dev/null +++ b/database/timescale/040_legend_exclusive_triple_totals.sql @@ -0,0 +1,56 @@ +-- +goose Up +-- Whole pet sets and selected siege are exclusive per attack. Equipment-pair +-- choices are exclusive for each hero, so their triples cannot exceed the +-- row's three-star count in the corresponding exclusive group. +-- +goose StatementBegin +CREATE FUNCTION public.legend_exclusive_triples_within_star_count(value jsonb, triple_limit bigint, per_hero boolean) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; + hero_id integer; + previous_hero integer := -1; + triples_value bigint; + total_triples bigint := 0; +BEGIN + IF triple_limit < 0 OR jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP + IF jsonb_typeof(entry) <> 'object' OR NOT entry ? 'triples' + OR jsonb_typeof(entry->'triples') <> 'number' OR entry->>'triples' !~ '^[0-9]+$' + OR (entry->>'triples')::numeric > 9223372036854775807 THEN RETURN false; END IF; + IF per_hero THEN + IF NOT entry ? 'heroId' OR jsonb_typeof(entry->'heroId') <> 'number' + OR entry->>'heroId' !~ '^[0-9]+$' + OR (entry->>'heroId')::numeric > 2147483647 THEN RETURN false; END IF; + hero_id := (entry->>'heroId')::integer; + IF hero_id <> previous_hero THEN total_triples := 0; END IF; + previous_hero := hero_id; + END IF; + triples_value := (entry->>'triples')::bigint; + IF triples_value > triple_limit - total_triples THEN RETURN false; END IF; + total_triples := total_triples + triples_value; + END LOOP; + RETURN true; +EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; +END +$$; +-- +goose StatementEnd + +ALTER TABLE public.legend_daily_stats + DROP CONSTRAINT legend_daily_stats_pet_combos_check, + ADD CONSTRAINT legend_daily_stats_pet_combos_check CHECK ( + public.pet_combo_usage_triples_within_attack_count(pet_combo_stats,attack_count) + AND public.legend_exclusive_triples_within_star_count(pet_combo_stats,three_star_count,false)), + DROP CONSTRAINT legend_daily_stats_siege_check, + ADD CONSTRAINT legend_daily_stats_siege_check CHECK ( + public.legend_siege_usage_within_attack_count(siege_stats,attack_count) + AND public.legend_exclusive_triples_within_star_count(siege_stats,three_star_count,false)), + DROP CONSTRAINT legend_daily_stats_equipment_pairs_check, + ADD CONSTRAINT legend_daily_stats_equipment_pairs_check CHECK ( + public.equipment_pair_usage_triples_within_attack_count(equipment_pair_stats,attack_count) + AND public.legend_exclusive_triples_within_star_count(equipment_pair_stats,three_star_count,true)); + +-- +goose Down +-- The stronger star totals cannot be rolled back without admitting impossible history. +-- +goose StatementBegin +DO $$ BEGIN RAISE EXCEPTION 'migration 040 is irreversible: exclusive triple checks must be retained'; END $$; +-- +goose StatementEnd diff --git a/scripts/retained-api-profile.sh b/scripts/retained-api-profile.sh index 865308e..a3d7f3f 100755 --- a/scripts/retained-api-profile.sh +++ b/scripts/retained-api-profile.sh @@ -1,5 +1,5 @@ # Canonical migration inventory for retained API tests and local development. -# This is the same contiguous 001-039 sequence used for production upgrades. +# This is the same contiguous 001-040 sequence used for production upgrades. fixture_image='timescale/timescaledb:2.29.2-pg18@sha256:9508616d5b941ed931198504c5db3fb47e8f53f790732ea1e889591f1062057c' fixture_sources=( 001_initial_stats.sql @@ -41,4 +41,5 @@ fixture_sources=( 037_roster_publication_requests.sql 038_daily_analytics_count_constraints.sql 039_legend_daily_exclusive_usage.sql + 040_legend_exclusive_triple_totals.sql ) diff --git a/scripts/with-test-timescale.test.mjs b/scripts/with-test-timescale.test.mjs index e736a0b..6d87f16 100644 --- a/scripts/with-test-timescale.test.mjs +++ b/scripts/with-test-timescale.test.mjs @@ -103,6 +103,7 @@ test('uses only retained authoritative migrations and its own disposable contain '037_roster_publication_requests.sql', '038_daily_analytics_count_constraints.sql', '039_legend_daily_exclusive_usage.sql', + '040_legend_exclusive_triple_totals.sql', ]); } assert.equal(migrations[1].args.at(-1), 'up'); From a07172d6550404242728c5b5a20164892a536e36 Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 25 Sep 2026 10:39:35 -0500 Subject: [PATCH 07/10] fix(database): bound item triples and index army references --- .../daily_analytics_constraints_test.go | 17 ++++++++ .../migrations/war_archive_schema_test.go | 1 + ...1_legend_item_triples_saved_army_index.sql | 39 +++++++++++++++++++ scripts/retained-api-profile.sh | 3 +- scripts/with-test-timescale.test.mjs | 1 + 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 database/timescale/041_legend_item_triples_saved_army_index.sql diff --git a/database/migrations/daily_analytics_constraints_test.go b/database/migrations/daily_analytics_constraints_test.go index 0bbe72a..e30d1a0 100644 --- a/database/migrations/daily_analytics_constraints_test.go +++ b/database/migrations/daily_analytics_constraints_test.go @@ -67,4 +67,21 @@ func TestDailyAnalyticsUsageConstraints(t *testing.T) { } }) } + for _, tc := range []struct { + name, value string + want bool + }{ + {"valid item triples for co-occurring items", `[{"id":1,"uses":3,"triples":2},{"id":2,"uses":3,"triples":2}]`, true}, + {"item triples exceed three stars", `[{"id":1,"uses":3,"triples":3}]`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var got bool + if err := conn.QueryRow(t.Context(), "SELECT public.legend_item_triples_within_star_count($1::jsonb, 2::bigint)", tc.value).Scan(&got); err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("validator returned %v, want %v", got, tc.want) + } + }) + } } diff --git a/database/migrations/war_archive_schema_test.go b/database/migrations/war_archive_schema_test.go index b5f9de1..a99e9dc 100644 --- a/database/migrations/war_archive_schema_test.go +++ b/database/migrations/war_archive_schema_test.go @@ -53,6 +53,7 @@ func TestTimescaleMigrationsAreExplicitlyNumbered(t *testing.T) { "038_daily_analytics_count_constraints.sql", "039_legend_daily_exclusive_usage.sql", "040_legend_exclusive_triple_totals.sql", + "041_legend_item_triples_saved_army_index.sql", } if len(files) != len(want) { t.Fatalf("Timescale migrations = %v, want %v", files, want) diff --git a/database/timescale/041_legend_item_triples_saved_army_index.sql b/database/timescale/041_legend_item_triples_saved_army_index.sql new file mode 100644 index 0000000..178f73f --- /dev/null +++ b/database/timescale/041_legend_item_triples_saved_army_index.sql @@ -0,0 +1,39 @@ +-- +goose Up +-- Troops and spells can coexist in one attack, so each item's triple count +-- is bounded separately by the day's three-star count. Do not sum items. +-- +goose StatementBegin +CREATE FUNCTION public.legend_item_triples_within_star_count(value jsonb, triple_limit bigint) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; +BEGIN + IF triple_limit < 0 OR NOT public.item_usage_triples_valid(value) THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) item(element) LOOP + IF (entry->>'triples')::bigint > triple_limit THEN RETURN false; END IF; + END LOOP; + RETURN true; +EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; +END +$$; +-- +goose StatementEnd + +ALTER TABLE public.legend_daily_stats + DROP CONSTRAINT legend_daily_stats_troops_check, + ADD CONSTRAINT legend_daily_stats_troops_check CHECK ( + public.item_usage_triples_within_attack_count(troop_stats,attack_count) + AND public.legend_item_triples_within_star_count(troop_stats,three_star_count)), + DROP CONSTRAINT legend_daily_stats_spells_check, + ADD CONSTRAINT legend_daily_stats_spells_check CHECK ( + public.item_usage_triples_within_attack_count(spell_stats,attack_count) + AND public.legend_item_triples_within_star_count(spell_stats,three_star_count)); + +-- The PK and recent-list index start with user_id; this reverse index makes +-- army_compositions(share_code) cascade checks indexable. +CREATE INDEX idx_user_saved_armies_share_code + ON public.user_saved_armies (share_code,user_id); + +-- +goose Down +-- Loosening the bound could admit impossible retained aggregate history. +-- +goose StatementBegin +DO $$ BEGIN RAISE EXCEPTION 'migration 041 is irreversible: Legend item triple bounds must be retained'; END $$; +-- +goose StatementEnd diff --git a/scripts/retained-api-profile.sh b/scripts/retained-api-profile.sh index a3d7f3f..e79e1f0 100755 --- a/scripts/retained-api-profile.sh +++ b/scripts/retained-api-profile.sh @@ -1,5 +1,5 @@ # Canonical migration inventory for retained API tests and local development. -# This is the same contiguous 001-040 sequence used for production upgrades. +# This is the same contiguous 001-041 sequence used for production upgrades. fixture_image='timescale/timescaledb:2.29.2-pg18@sha256:9508616d5b941ed931198504c5db3fb47e8f53f790732ea1e889591f1062057c' fixture_sources=( 001_initial_stats.sql @@ -42,4 +42,5 @@ fixture_sources=( 038_daily_analytics_count_constraints.sql 039_legend_daily_exclusive_usage.sql 040_legend_exclusive_triple_totals.sql + 041_legend_item_triples_saved_army_index.sql ) diff --git a/scripts/with-test-timescale.test.mjs b/scripts/with-test-timescale.test.mjs index 6d87f16..12f8076 100644 --- a/scripts/with-test-timescale.test.mjs +++ b/scripts/with-test-timescale.test.mjs @@ -104,6 +104,7 @@ test('uses only retained authoritative migrations and its own disposable contain '038_daily_analytics_count_constraints.sql', '039_legend_daily_exclusive_usage.sql', '040_legend_exclusive_triple_totals.sql', + '041_legend_item_triples_saved_army_index.sql', ]); } assert.equal(migrations[1].args.at(-1), 'up'); From 465ef44ad087a611018991b0ed7a1a202f3efec4 Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 25 Sep 2026 10:50:37 -0500 Subject: [PATCH 08/10] fix(database): validate CWL town hall distribution --- .github/workflows/schema-upgrade.yml | 2 +- .../cwl_participation_constraints_test.go | 65 +++++++++++++++++++ .../migrations/war_archive_schema_test.go | 1 + ..._cwl_participation_townhall_validation.sql | 42 ++++++++++++ scripts/retained-api-profile.sh | 3 +- scripts/with-test-timescale.test.mjs | 1 + 6 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 database/migrations/cwl_participation_constraints_test.go create mode 100644 database/timescale/042_cwl_participation_townhall_validation.sql diff --git a/.github/workflows/schema-upgrade.yml b/.github/workflows/schema-upgrade.yml index 794a001..cfb74d7 100644 --- a/.github/workflows/schema-upgrade.yml +++ b/.github/workflows/schema-upgrade.yml @@ -35,4 +35,4 @@ jobs: run: bash ../scripts/with-test-timescale.sh --profile baseline-021 -- go test ./schema -run '^TestPersonalArmyStorageMigration$' -count=1 -v - name: Verify daily analytics constraints on the complete retained schema working-directory: database - run: bash ../scripts/with-test-timescale.sh --profile retained-api -- go test ./migrations -run '^TestDailyAnalyticsUsageConstraints$' -count=1 -v + run: bash ../scripts/with-test-timescale.sh --profile retained-api -- go test ./migrations -run '^(TestDailyAnalyticsUsageConstraints|TestCWLParticipationTownHalls)$' -count=1 -v diff --git a/database/migrations/cwl_participation_constraints_test.go b/database/migrations/cwl_participation_constraints_test.go new file mode 100644 index 0000000..47555b0 --- /dev/null +++ b/database/migrations/cwl_participation_constraints_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5" +) + +func TestCWLParticipationTownHalls(t *testing.T) { + if os.Getenv("CLASHKING_DISPOSABLE_TIMESCALE") != "1" || os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("requires disposable authoritative Goose schema") + } + conn, err := pgx.Connect(t.Context(), os.Getenv("TEST_DATABASE_URL")) + if err != nil { + t.Fatal(err) + } + defer conn.Close(context.Background()) + for _, tc := range []struct { + name, value string + want bool + }{ + {"empty distribution", `[]`, true}, + {"ordered distribution", `[{"level":18,"count":4},{"level":17,"count":2}]`, true}, + {"null entry", `[null]`, false}, + {"string level", `[{"level":"17","count":2}]`, false}, + {"negative count", `[{"level":17,"count":-1}]`, false}, + {"duplicate level", `[{"level":17,"count":1},{"level":17,"count":2}]`, false}, + {"ascending levels", `[{"level":17,"count":1},{"level":18,"count":2}]`, false}, + {"unknown key", `[{"level":17,"count":1,"other":true}]`, false}, + {"out of range level", `[{"level":21,"count":1}]`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var got bool + if err := conn.QueryRow(t.Context(), "SELECT public.cwl_participation_town_halls_valid($1::jsonb)", tc.value).Scan(&got); err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("validator returned %v, want %v", got, tc.want) + } + }) + } + for _, tc := range []struct { + name, value string + wantErr bool + }{ + {"valid row", `[{"level":18,"count":4}]`, false}, + {"malformed row", `[{"level":"18","count":4}]`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + tx, err := conn.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + _, err = tx.Exec(t.Context(), `INSERT INTO public.cwl_participation + (season,cwl_league_id,war_size,group_count,clan_count,registered_player_count,townhall_counts,finalized_wars,archived_wars) + VALUES ('2026-09',48000001,15,1,1,1,$1::jsonb,0,0)`, tc.value) + if (err != nil) != tc.wantErr { + t.Fatalf("insert error = %v, want error = %v", err, tc.wantErr) + } + }) + } +} diff --git a/database/migrations/war_archive_schema_test.go b/database/migrations/war_archive_schema_test.go index a99e9dc..52d63bc 100644 --- a/database/migrations/war_archive_schema_test.go +++ b/database/migrations/war_archive_schema_test.go @@ -54,6 +54,7 @@ func TestTimescaleMigrationsAreExplicitlyNumbered(t *testing.T) { "039_legend_daily_exclusive_usage.sql", "040_legend_exclusive_triple_totals.sql", "041_legend_item_triples_saved_army_index.sql", + "042_cwl_participation_townhall_validation.sql", } if len(files) != len(want) { t.Fatalf("Timescale migrations = %v, want %v", files, want) diff --git a/database/timescale/042_cwl_participation_townhall_validation.sql b/database/timescale/042_cwl_participation_townhall_validation.sql new file mode 100644 index 0000000..1f2fe3f --- /dev/null +++ b/database/timescale/042_cwl_participation_townhall_validation.sql @@ -0,0 +1,42 @@ +-- +goose Up +-- Restore the canonical descending, typed TH distribution validation from +-- the retired CWL aggregate for the retained participation summary. +-- +goose StatementBegin +CREATE FUNCTION public.cwl_participation_town_halls_valid(value jsonb) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; + previous_level integer := 21; + level_value integer; +BEGIN + IF jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP + IF jsonb_typeof(entry) <> 'object' + OR (SELECT count(*) FROM jsonb_object_keys(entry)) <> 2 + OR NOT (entry ? 'level' AND entry ? 'count') + OR jsonb_typeof(entry->'level') <> 'number' + OR jsonb_typeof(entry->'count') <> 'number' + OR entry->>'level' !~ '^[0-9]+$' + OR entry->>'count' !~ '^[0-9]+$' THEN RETURN false; END IF; + level_value := (entry->>'level')::integer; + IF level_value NOT BETWEEN 1 AND 20 + OR (entry->>'count')::numeric > 9223372036854775807 + OR level_value >= previous_level THEN RETURN false; END IF; + previous_level := level_value; + END LOOP; + RETURN true; +EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; +END +$$; +-- +goose StatementEnd + +ALTER TABLE public.cwl_participation + DROP CONSTRAINT cwl_participation_townhall_check, + ADD CONSTRAINT cwl_participation_townhall_check + CHECK (public.cwl_participation_town_halls_valid(townhall_counts)); + +-- +goose Down +-- Loosening validation could retain malformed analytics history. +-- +goose StatementBegin +DO $$ BEGIN RAISE EXCEPTION 'migration 042 is irreversible: CWL town-hall validation must be retained'; END $$; +-- +goose StatementEnd diff --git a/scripts/retained-api-profile.sh b/scripts/retained-api-profile.sh index e79e1f0..00ac578 100755 --- a/scripts/retained-api-profile.sh +++ b/scripts/retained-api-profile.sh @@ -1,5 +1,5 @@ # Canonical migration inventory for retained API tests and local development. -# This is the same contiguous 001-041 sequence used for production upgrades. +# This is the same contiguous 001-042 sequence used for production upgrades. fixture_image='timescale/timescaledb:2.29.2-pg18@sha256:9508616d5b941ed931198504c5db3fb47e8f53f790732ea1e889591f1062057c' fixture_sources=( 001_initial_stats.sql @@ -43,4 +43,5 @@ fixture_sources=( 039_legend_daily_exclusive_usage.sql 040_legend_exclusive_triple_totals.sql 041_legend_item_triples_saved_army_index.sql + 042_cwl_participation_townhall_validation.sql ) diff --git a/scripts/with-test-timescale.test.mjs b/scripts/with-test-timescale.test.mjs index 12f8076..cacec5b 100644 --- a/scripts/with-test-timescale.test.mjs +++ b/scripts/with-test-timescale.test.mjs @@ -105,6 +105,7 @@ test('uses only retained authoritative migrations and its own disposable contain '039_legend_daily_exclusive_usage.sql', '040_legend_exclusive_triple_totals.sql', '041_legend_item_triples_saved_army_index.sql', + '042_cwl_participation_townhall_validation.sql', ]); } assert.equal(migrations[1].args.at(-1), 'up'); From a671f095f0dd1ef24eb91926cd97c573a3d042cd Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 25 Sep 2026 11:00:51 -0500 Subject: [PATCH 09/10] fix(database): require selected siege IDs --- .../daily_analytics_constraints_test.go | 17 +++++++++++ .../migrations/war_archive_schema_test.go | 1 + .../043_legend_selected_siege_id.sql | 30 +++++++++++++++++++ scripts/retained-api-profile.sh | 3 +- scripts/with-test-timescale.test.mjs | 1 + 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 database/timescale/043_legend_selected_siege_id.sql diff --git a/database/migrations/daily_analytics_constraints_test.go b/database/migrations/daily_analytics_constraints_test.go index e30d1a0..f538873 100644 --- a/database/migrations/daily_analytics_constraints_test.go +++ b/database/migrations/daily_analytics_constraints_test.go @@ -17,6 +17,23 @@ func TestDailyAnalyticsUsageConstraints(t *testing.T) { t.Fatal(err) } defer conn.Close(context.Background()) + for _, tc := range []struct { + name, value string + want bool + }{ + {"valid selected siege", `[{"id":1,"uses":1,"triples":0}]`, true}, + {"zero selected siege", `[{"id":0,"uses":1,"triples":0}]`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var got bool + if err := conn.QueryRow(t.Context(), "SELECT public.legend_selected_siege_ids_valid($1::jsonb)", tc.value).Scan(&got); err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("validator returned %v, want %v", got, tc.want) + } + }) + } for _, tc := range []struct { name, function, value string want bool diff --git a/database/migrations/war_archive_schema_test.go b/database/migrations/war_archive_schema_test.go index 52d63bc..9583c0c 100644 --- a/database/migrations/war_archive_schema_test.go +++ b/database/migrations/war_archive_schema_test.go @@ -55,6 +55,7 @@ func TestTimescaleMigrationsAreExplicitlyNumbered(t *testing.T) { "040_legend_exclusive_triple_totals.sql", "041_legend_item_triples_saved_army_index.sql", "042_cwl_participation_townhall_validation.sql", + "043_legend_selected_siege_id.sql", } if len(files) != len(want) { t.Fatalf("Timescale migrations = %v, want %v", files, want) diff --git a/database/timescale/043_legend_selected_siege_id.sql b/database/timescale/043_legend_selected_siege_id.sql new file mode 100644 index 0000000..db9130f --- /dev/null +++ b/database/timescale/043_legend_selected_siege_id.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- Tracking omits the zero sentinel when no siege was selected. A retained +-- selected-siege bucket must therefore have a positive canonical ID. +-- +goose StatementBegin +CREATE FUNCTION public.legend_selected_siege_ids_valid(value jsonb) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; +BEGIN + IF NOT public.item_usage_triples_valid(value) THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) item(element) LOOP + IF (entry->>'id')::integer = 0 THEN RETURN false; END IF; + END LOOP; + RETURN true; +END +$$; +-- +goose StatementEnd + +ALTER TABLE public.legend_daily_stats + DROP CONSTRAINT legend_daily_stats_siege_check, + ADD CONSTRAINT legend_daily_stats_siege_check CHECK ( + public.legend_selected_siege_ids_valid(siege_stats) + AND public.legend_siege_usage_within_attack_count(siege_stats,attack_count) + AND public.legend_exclusive_triples_within_star_count(siege_stats,three_star_count,false)); + +-- +goose Down +-- Loosening selected-siege identity validation could retain invalid history. +-- +goose StatementBegin +DO $$ BEGIN RAISE EXCEPTION 'migration 043 is irreversible: selected-siege IDs must remain positive'; END $$; +-- +goose StatementEnd diff --git a/scripts/retained-api-profile.sh b/scripts/retained-api-profile.sh index 00ac578..b25176d 100755 --- a/scripts/retained-api-profile.sh +++ b/scripts/retained-api-profile.sh @@ -1,5 +1,5 @@ # Canonical migration inventory for retained API tests and local development. -# This is the same contiguous 001-042 sequence used for production upgrades. +# This is the same contiguous 001-043 sequence used for production upgrades. fixture_image='timescale/timescaledb:2.29.2-pg18@sha256:9508616d5b941ed931198504c5db3fb47e8f53f790732ea1e889591f1062057c' fixture_sources=( 001_initial_stats.sql @@ -44,4 +44,5 @@ fixture_sources=( 040_legend_exclusive_triple_totals.sql 041_legend_item_triples_saved_army_index.sql 042_cwl_participation_townhall_validation.sql + 043_legend_selected_siege_id.sql ) diff --git a/scripts/with-test-timescale.test.mjs b/scripts/with-test-timescale.test.mjs index cacec5b..d6a4f3d 100644 --- a/scripts/with-test-timescale.test.mjs +++ b/scripts/with-test-timescale.test.mjs @@ -106,6 +106,7 @@ test('uses only retained authoritative migrations and its own disposable contain '040_legend_exclusive_triple_totals.sql', '041_legend_item_triples_saved_army_index.sql', '042_cwl_participation_townhall_validation.sql', + '043_legend_selected_siege_id.sql', ]); } assert.equal(migrations[1].args.at(-1), 'up'); From c5fc3ec4db2a6502ee36ac56d8f399b356e4d50e Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 25 Sep 2026 11:21:15 -0500 Subject: [PATCH 10/10] fix(database): validate CWL hit-rate observations --- .github/workflows/schema-upgrade.yml | 2 +- .../cwl_participation_constraints_test.go | 63 +++++++++++++++++++ .../migrations/war_archive_schema_test.go | 1 + ...4_cwl_participation_hitrate_validation.sql | 49 +++++++++++++++ scripts/retained-api-profile.sh | 3 +- scripts/with-test-timescale.test.mjs | 1 + 6 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 database/timescale/044_cwl_participation_hitrate_validation.sql diff --git a/.github/workflows/schema-upgrade.yml b/.github/workflows/schema-upgrade.yml index cfb74d7..b12efb3 100644 --- a/.github/workflows/schema-upgrade.yml +++ b/.github/workflows/schema-upgrade.yml @@ -35,4 +35,4 @@ jobs: run: bash ../scripts/with-test-timescale.sh --profile baseline-021 -- go test ./schema -run '^TestPersonalArmyStorageMigration$' -count=1 -v - name: Verify daily analytics constraints on the complete retained schema working-directory: database - run: bash ../scripts/with-test-timescale.sh --profile retained-api -- go test ./migrations -run '^(TestDailyAnalyticsUsageConstraints|TestCWLParticipationTownHalls)$' -count=1 -v + run: bash ../scripts/with-test-timescale.sh --profile retained-api -- go test ./migrations -run '^(TestDailyAnalyticsUsageConstraints|TestCWLParticipationTownHalls|TestCWLParticipationHitRates)$' -count=1 -v diff --git a/database/migrations/cwl_participation_constraints_test.go b/database/migrations/cwl_participation_constraints_test.go index 47555b0..53dc65c 100644 --- a/database/migrations/cwl_participation_constraints_test.go +++ b/database/migrations/cwl_participation_constraints_test.go @@ -63,3 +63,66 @@ func TestCWLParticipationTownHalls(t *testing.T) { }) } } + +func TestCWLParticipationHitRates(t *testing.T) { + if os.Getenv("CLASHKING_DISPOSABLE_TIMESCALE") != "1" || os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("requires disposable authoritative Goose schema") + } + conn, err := pgx.Connect(t.Context(), os.Getenv("TEST_DATABASE_URL")) + if err != nil { + t.Fatal(err) + } + defer conn.Close(context.Background()) + for _, tc := range []struct { + name, value string + want bool + }{ + {"empty observations", `[]`, true}, + {"descending observations", `[{"level":18,"attacks":4,"three_stars":2},{"level":17,"attacks":3,"three_stars":1}]`, true}, + {"null entry", `[null]`, false}, + {"missing fields", `[{}]`, false}, + {"string level", `[{"level":"18","attacks":1,"three_stars":0}]`, false}, + {"negative attempts", `[{"level":18,"attacks":-1,"three_stars":0}]`, false}, + {"triples over attempts", `[{"level":18,"attacks":1,"three_stars":2}]`, false}, + {"duplicate level", `[{"level":18,"attacks":1,"three_stars":0},{"level":18,"attacks":1,"three_stars":0}]`, false}, + {"ascending levels", `[{"level":17,"attacks":1,"three_stars":0},{"level":18,"attacks":1,"three_stars":0}]`, false}, + {"unknown key", `[{"level":18,"attacks":1,"three_stars":0,"other":true}]`, false}, + {"out of range level", `[{"level":21,"attacks":1,"three_stars":0}]`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var got bool + if err := conn.QueryRow(t.Context(), "SELECT public.cwl_participation_hitrates_valid($1::jsonb)", tc.value).Scan(&got); err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("validator returned %v, want %v", got, tc.want) + } + }) + } + for _, tc := range []struct { + name, value string + wantErr bool + }{ + {"SQL null observations", "", false}, + {"valid row", `[{"level":18,"attacks":1,"three_stars":1}]`, false}, + {"malformed row", `[{}]`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + tx, err := conn.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + var input any = tc.value + if tc.name == "SQL null observations" { + input = nil + } + _, err = tx.Exec(t.Context(), `INSERT INTO public.cwl_participation + (season,cwl_league_id,war_size,group_count,clan_count,registered_player_count,townhall_counts,same_th_hitrates,finalized_wars,archived_wars) + VALUES ('2026-09',48000001,15,1,1,1,'[]',$1::jsonb,0,0)`, input) + if (err != nil) != tc.wantErr { + t.Fatalf("insert error = %v, want error = %v", err, tc.wantErr) + } + }) + } +} diff --git a/database/migrations/war_archive_schema_test.go b/database/migrations/war_archive_schema_test.go index 9583c0c..85e17ff 100644 --- a/database/migrations/war_archive_schema_test.go +++ b/database/migrations/war_archive_schema_test.go @@ -56,6 +56,7 @@ func TestTimescaleMigrationsAreExplicitlyNumbered(t *testing.T) { "041_legend_item_triples_saved_army_index.sql", "042_cwl_participation_townhall_validation.sql", "043_legend_selected_siege_id.sql", + "044_cwl_participation_hitrate_validation.sql", } if len(files) != len(want) { t.Fatalf("Timescale migrations = %v, want %v", files, want) diff --git a/database/timescale/044_cwl_participation_hitrate_validation.sql b/database/timescale/044_cwl_participation_hitrate_validation.sql new file mode 100644 index 0000000..45eb4db --- /dev/null +++ b/database/timescale/044_cwl_participation_hitrate_validation.sql @@ -0,0 +1,49 @@ +-- +goose Up +-- Manually rebuilt same-TH observations use a canonical descending set of +-- integer town-hall buckets, never an arbitrary JSON array. +-- +goose StatementBegin +CREATE FUNCTION public.cwl_participation_hitrates_valid(value jsonb) +RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE AS $$ +DECLARE + entry jsonb; + previous_level integer := 21; + level_value integer; + attempts bigint; + triples bigint; +BEGIN + IF jsonb_typeof(value) <> 'array' THEN RETURN false; END IF; + FOR entry IN SELECT element FROM jsonb_array_elements(value) WITH ORDINALITY AS item(element, position) ORDER BY position LOOP + IF jsonb_typeof(entry) <> 'object' + OR (SELECT count(*) FROM jsonb_object_keys(entry)) <> 3 + OR NOT (entry ? 'level' AND entry ? 'attacks' AND entry ? 'three_stars') + OR jsonb_typeof(entry->'level') <> 'number' + OR jsonb_typeof(entry->'attacks') <> 'number' + OR jsonb_typeof(entry->'three_stars') <> 'number' + OR entry->>'level' !~ '^[0-9]+$' + OR entry->>'attacks' !~ '^[0-9]+$' + OR entry->>'three_stars' !~ '^[0-9]+$' + OR (entry->>'attacks')::numeric > 9223372036854775807 + OR (entry->>'three_stars')::numeric > 9223372036854775807 THEN RETURN false; END IF; + level_value := (entry->>'level')::integer; + attempts := (entry->>'attacks')::bigint; + triples := (entry->>'three_stars')::bigint; + IF level_value NOT BETWEEN 1 AND 20 OR level_value >= previous_level + OR triples > attempts THEN RETURN false; END IF; + previous_level := level_value; + END LOOP; + RETURN true; +EXCEPTION WHEN numeric_value_out_of_range THEN RETURN false; +END +$$; +-- +goose StatementEnd + +ALTER TABLE public.cwl_participation + DROP CONSTRAINT cwl_participation_hitrates_check, + ADD CONSTRAINT cwl_participation_hitrates_check CHECK ( + same_th_hitrates IS NULL OR public.cwl_participation_hitrates_valid(same_th_hitrates)); + +-- +goose Down +-- Loosening retained CWL summary validation could admit malformed history. +-- +goose StatementBegin +DO $$ BEGIN RAISE EXCEPTION 'migration 044 is irreversible: CWL hit-rate validation must be retained'; END $$; +-- +goose StatementEnd diff --git a/scripts/retained-api-profile.sh b/scripts/retained-api-profile.sh index b25176d..fe23ab6 100755 --- a/scripts/retained-api-profile.sh +++ b/scripts/retained-api-profile.sh @@ -1,5 +1,5 @@ # Canonical migration inventory for retained API tests and local development. -# This is the same contiguous 001-043 sequence used for production upgrades. +# This is the same contiguous 001-044 sequence used for production upgrades. fixture_image='timescale/timescaledb:2.29.2-pg18@sha256:9508616d5b941ed931198504c5db3fb47e8f53f790732ea1e889591f1062057c' fixture_sources=( 001_initial_stats.sql @@ -45,4 +45,5 @@ fixture_sources=( 041_legend_item_triples_saved_army_index.sql 042_cwl_participation_townhall_validation.sql 043_legend_selected_siege_id.sql + 044_cwl_participation_hitrate_validation.sql ) diff --git a/scripts/with-test-timescale.test.mjs b/scripts/with-test-timescale.test.mjs index d6a4f3d..c0337e4 100644 --- a/scripts/with-test-timescale.test.mjs +++ b/scripts/with-test-timescale.test.mjs @@ -107,6 +107,7 @@ test('uses only retained authoritative migrations and its own disposable contain '041_legend_item_triples_saved_army_index.sql', '042_cwl_participation_townhall_validation.sql', '043_legend_selected_siege_id.sql', + '044_cwl_participation_hitrate_validation.sql', ]); } assert.equal(migrations[1].args.at(-1), 'up');