mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-22 23:18:05 +03:00
* checkpoint * server compiles again * fmt * checkpoint * last one out, get the lights * euston station * like, mostly good. I'm under the ocean right now. * fix: some nonsense AI stuff * fix: new eslint rules, and cleanup imports across the board * feat: folder slug rewrite, email logic change, too * fix: register must use txn not db * checkpoint
1238 lines
36 KiB
PL/PgSQL
1238 lines
36 KiB
PL/PgSQL
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
-- Query stats (requires shared_preload_libraries=pg_stat_statements; see docker-compose-dev.yml).
|
|
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
|
-- auto_explain is loaded via shared_preload_libraries in dev (same docker-compose).
|
|
|
|
-- Tables starting with "priv_" are private and should never ever be exposed
|
|
-- everything else is assumed to be sound, public information.
|
|
|
|
-- ==> Enums
|
|
CREATE TYPE auth_level AS ENUM ('banned', 'user', 'admin');
|
|
CREATE TYPE account_badge_kind AS ENUM ('beta', 'alpha', 'dev-team');
|
|
CREATE TYPE game AS ENUM (
|
|
'iidx-sp',
|
|
'iidx-dp',
|
|
'museca',
|
|
'sdvx',
|
|
'bms-14k',
|
|
'bms-7k',
|
|
'gitadora-dora',
|
|
'gitadora-gita',
|
|
'chunithm',
|
|
'wacca',
|
|
'jubeat',
|
|
'popn',
|
|
'maimai',
|
|
'maimaidx',
|
|
'pms-controller',
|
|
'pms-keyboard',
|
|
'usc-controller',
|
|
'usc-keyboard',
|
|
'itg-stamina',
|
|
'arcaea',
|
|
'ongeki',
|
|
'ddr-sp',
|
|
'ddr-dp'
|
|
);
|
|
CREATE TYPE import_type AS ENUM (
|
|
'file/batch-manual',
|
|
'file/eamusement-iidx-csv',
|
|
'file/eamusement-sdvx-csv',
|
|
'file/mypagescraper-player-csv',
|
|
'file/mypagescraper-records-csv',
|
|
'file/pli-iidx-csv',
|
|
'file/solid-state-squad',
|
|
|
|
'api/arc-iidx', -- REMOVED import type OMGWTFBBQ
|
|
'api/arc-sdvx', -- REMOVED import type OMGWTFBBQ
|
|
'api/mer-iidx', -- REMOVED import type OMGWTFBBQ
|
|
'api/eag-iidx',
|
|
'api/eag-sdvx',
|
|
'api/flo-iidx',
|
|
'api/flo-sdvx',
|
|
'api/min-sdvx',
|
|
'api/myt-chunithm',
|
|
'api/myt-maimaidx',
|
|
'api/myt-ongeki',
|
|
'api/myt-wacca',
|
|
'api/cg-dev-jubeat',
|
|
'api/cg-dev-museca',
|
|
'api/cg-dev-popn',
|
|
'api/cg-dev-sdvx',
|
|
'api/cg-gan-jubeat',
|
|
'api/cg-gan-museca',
|
|
'api/cg-gan-popn',
|
|
'api/cg-gan-sdvx',
|
|
'api/cg-nag-jubeat',
|
|
'api/cg-nag-museca',
|
|
'api/cg-nag-popn',
|
|
'api/cg-nag-sdvx',
|
|
|
|
'ir/barbatos',
|
|
'ir/beatoraja',
|
|
'ir/direct-manual',
|
|
'ir/fervidex-static',
|
|
'ir/fervidex',
|
|
'ir/kshook-sv6c-static',
|
|
'ir/kshook-sv6c',
|
|
'ir/lr2hook',
|
|
'ir/usc'
|
|
);
|
|
|
|
-- A game group is used for songs; it is a grouping for games
|
|
-- that logically re-use song data and share them between multiple
|
|
-- sub games. This is rare, but the only real example is SP/DP
|
|
-- in IIDX and DDR.
|
|
CREATE TYPE game_group AS ENUM (
|
|
'iidx',
|
|
'museca',
|
|
'chunithm',
|
|
'bms',
|
|
'gitadora',
|
|
'jubeat',
|
|
'maimai',
|
|
'maimaidx',
|
|
'popn',
|
|
'sdvx',
|
|
'usc',
|
|
'wacca',
|
|
'pms',
|
|
'itg',
|
|
'arcaea',
|
|
'ongeki',
|
|
'ddr'
|
|
);
|
|
|
|
-- <== End enums
|
|
|
|
-- ==> Essentials for Zenith-style infrastructure
|
|
|
|
-- This is our postgres-backed job queue.
|
|
-- https://kerkour.com/rust-job-queue-with-postgresql
|
|
CREATE TABLE "job_queue" (
|
|
row_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
|
scheduled_for TIMESTAMPTZ NOT NULL,
|
|
failed_attempts INT NOT NULL DEFAULT 0,
|
|
status INT NOT NULL,
|
|
|
|
scope TEXT NOT NULL,
|
|
job_kind TEXT NOT NULL,
|
|
|
|
payload JSONB NOT NULL
|
|
);
|
|
CREATE INDEX job_queue_partial_index_btree
|
|
ON job_queue (created_at, status)
|
|
WHERE status = 0;
|
|
|
|
CREATE TABLE "cron_task" (
|
|
id TEXT PRIMARY KEY,
|
|
schedule TEXT NOT NULL,
|
|
description TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
|
-- The cron fire time of the last run we dispatched. Used to determine
|
|
-- what is due on the next tick, and to implement skip-missed-run semantics.
|
|
last_scheduled_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE TABLE "cron_task_execution" (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
|
|
-- The cron fire time this execution corresponds to (not the wall time it started).
|
|
scheduled_at TIMESTAMPTZ NOT NULL,
|
|
|
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
completed_at TIMESTAMPTZ,
|
|
|
|
status TEXT NOT NULL CHECK (status IN ('running', 'success', 'failure')),
|
|
output TEXT,
|
|
error TEXT
|
|
);
|
|
|
|
-- this table is likely to be made by the migration engine
|
|
-- so this code will likely never proc.
|
|
CREATE TABLE IF NOT EXISTS "_migration" (
|
|
version BIGINT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
installed_on TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
success BOOLEAN NOT NULL,
|
|
checksum BYTEA NOT NULL,
|
|
execution_time BIGINT NOT NULL
|
|
);
|
|
|
|
-- <== End essentials
|
|
|
|
CREATE TABLE "account" (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
username TEXT UNIQUE NOT NULL,
|
|
normalized_username TEXT GENERATED ALWAYS AS (LOWER(username)) STORED UNIQUE NOT NULL,
|
|
|
|
sm_discord TEXT,
|
|
sm_twitter TEXT,
|
|
sm_github TEXT,
|
|
sm_steam TEXT,
|
|
sm_youtube TEXT,
|
|
sm_twitch TEXT,
|
|
|
|
bd_alpha BOOLEAN NOT NULL DEFAULT FALSE,
|
|
bd_beta BOOLEAN NOT NULL DEFAULT FALSE,
|
|
bd_dev_team BOOLEAN NOT NULL DEFAULT FALSE,
|
|
|
|
joined TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
about TEXT CHECK (LENGTH(about) <= 2000) NOT NULL,
|
|
status TEXT CHECK (LENGTH(status) <= 140),
|
|
|
|
custom_pfp_location TEXT,
|
|
custom_banner_location TEXT,
|
|
|
|
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
|
auth_level AUTH_LEVEL NOT NULL DEFAULT 'user',
|
|
is_supporter BOOLEAN NOT NULL DEFAULT FALSE
|
|
);
|
|
|
|
-- ==> Another Zenith Essential
|
|
CREATE TYPE action_result AS ENUM ('GOOD', 'BAD', 'THROW');
|
|
|
|
CREATE TABLE "action" (
|
|
row_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
user_id BIGINT REFERENCES account(id),
|
|
ip INET,
|
|
app TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
result ACTION_RESULT NOT NULL,
|
|
input JSONB NOT NULL,
|
|
output JSONB,
|
|
|
|
-- start/end lets you work out action durations
|
|
ts_start TIMESTAMPTZ NOT NULL,
|
|
ts_end TIMESTAMPTZ NOT NULL
|
|
);
|
|
-- TODO Partition this table
|
|
|
|
CREATE INDEX ON "action" (user_id, ts_start DESC);
|
|
CREATE INDEX ON "action" (ip, ts_start DESC);
|
|
CREATE INDEX ON "action" (app, kind, ts_start DESC);
|
|
-- <== End another Zenith Essential
|
|
|
|
CREATE TABLE "account_settings" (
|
|
user_id BIGINT REFERENCES account(id) PRIMARY KEY NOT NULL,
|
|
pf_invisible BOOLEAN NOT NULL,
|
|
pf_developer_mode BOOLEAN NOT NULL,
|
|
-- TODO(zk): Unused, can be ripped out
|
|
pf_advanced_mode BOOLEAN NOT NULL,
|
|
-- TODO(zk): Unused, can be ripped out
|
|
pf_contentious_content BOOLEAN NOT NULL,
|
|
pf_deletable_scores BOOLEAN NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "account_following" (
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
followee BIGINT REFERENCES account(id) NOT NULL,
|
|
|
|
PRIMARY KEY (user_id, followee),
|
|
CHECK (user_id != followee)
|
|
);
|
|
|
|
CREATE TABLE "account_username_change" (
|
|
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
username TEXT NOT NULL,
|
|
previous_username TEXT NOT NULL,
|
|
timestamp TIMESTAMPTZ NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "priv_account_credential" (
|
|
user_id BIGINT REFERENCES account(id) PRIMARY KEY NOT NULL,
|
|
-- bcrypt
|
|
password TEXT NOT NULL,
|
|
-- unencrypted
|
|
email TEXT NOT NULL UNIQUE
|
|
);
|
|
|
|
CREATE TABLE "priv_api_client" (
|
|
client_id TEXT PRIMARY KEY NOT NULL,
|
|
client_secret TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
author BIGINT REFERENCES account(id) NOT NULL,
|
|
|
|
pm_customise_profile BOOLEAN,
|
|
pm_customise_score BOOLEAN,
|
|
pm_customise_session BOOLEAN,
|
|
pm_delete_score BOOLEAN,
|
|
pm_manage_rivals BOOLEAN,
|
|
pm_manage_targets BOOLEAN,
|
|
pm_submit_score BOOLEAN,
|
|
pm_manage_challenges BOOLEAN,
|
|
|
|
api_key_template TEXT CHECK (api_key_template ~ '%%TACHI_KEY%%'),
|
|
api_key_filename TEXT,
|
|
webhook_uri TEXT,
|
|
redirect_uri TEXT,
|
|
is_builtin BOOLEAN NOT NULL DEFAULT FALSE
|
|
);
|
|
|
|
CREATE TABLE "priv_api_token" (
|
|
token TEXT PRIMARY KEY NOT NULL,
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
identifier TEXT NOT NULL,
|
|
|
|
pm_customise_profile BOOLEAN,
|
|
pm_customise_score BOOLEAN,
|
|
pm_customise_session BOOLEAN,
|
|
pm_delete_score BOOLEAN,
|
|
pm_manage_rivals BOOLEAN,
|
|
pm_manage_targets BOOLEAN,
|
|
pm_submit_score BOOLEAN,
|
|
pm_manage_challenges BOOLEAN,
|
|
|
|
from_oauth2_client TEXT REFERENCES priv_api_client(client_id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE "priv_invite" (
|
|
code TEXT PRIMARY KEY NOT NULL,
|
|
created_by BIGINT REFERENCES account(id) NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL,
|
|
|
|
consumed BOOLEAN NOT NULL,
|
|
consumed_by BIGINT REFERENCES account(id),
|
|
consumed_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE TABLE "priv_verify_email_token" (
|
|
token TEXT PRIMARY KEY NOT NULL,
|
|
email TEXT NOT NULL,
|
|
user_id BIGINT REFERENCES account(id) NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "priv_password_reset_token" (
|
|
token TEXT PRIMARY KEY NOT NULL,
|
|
created_on TIMESTAMPTZ NOT NULL,
|
|
user_id BIGINT REFERENCES account(id) NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "priv_oauth2_auth_token" (
|
|
token TEXT PRIMARY KEY NOT NULL,
|
|
created_on TIMESTAMPTZ NOT NULL,
|
|
user_id BIGINT REFERENCES account(id) NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "priv_svc_myt_card_info" (
|
|
-- TODO(zk) what's the primacy here?
|
|
-- One card access code => one user, definitely
|
|
-- but should you be able to guess that card IDs are in use
|
|
-- by submitting cards to this endpoint?
|
|
--
|
|
-- n.g.
|
|
card_access_code TEXT PRIMARY KEY,
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
|
|
-- One MYT card integration row per account (upsert target for UPDATE_MYT_CARD_INFO).
|
|
CONSTRAINT priv_svc_myt_card_info_user_id_key UNIQUE (user_id)
|
|
);
|
|
|
|
CREATE TABLE "priv_svc_cg_card_info" (
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
-- TODO(zk) these should be uppercased to be consistent with
|
|
-- kai
|
|
service TEXT NOT NULL CHECK (service IN ('dev', 'gan', 'nag')),
|
|
|
|
PRIMARY KEY (user_id, service),
|
|
|
|
card_id TEXT NOT NULL,
|
|
pin TEXT NOT NULL CHECK (pin ~ '^[0-9]{4}$')
|
|
);
|
|
|
|
CREATE TABLE "priv_svc_kai_auth_token" (
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
service TEXT NOT NULL CHECK (service IN ('FLO', 'EAG', 'MIN')),
|
|
PRIMARY KEY (user_id, service),
|
|
|
|
token TEXT NOT NULL,
|
|
refresh_token TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "svc_fer_settings" (
|
|
user_id BIGINT REFERENCES account(id) PRIMARY KEY NOT NULL,
|
|
force_static_import BOOLEAN NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "priv_svc_fer_card" (
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
card_id TEXT NOT NULL,
|
|
|
|
PRIMARY KEY (user_id, card_id)
|
|
);
|
|
|
|
CREATE TABLE "svc_kshook_sv6c_settings" (
|
|
user_id BIGINT REFERENCES account(id) PRIMARY KEY NOT NULL,
|
|
force_static_import BOOLEAN NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "import_lock" (
|
|
user_id BIGINT REFERENCES account(id) PRIMARY KEY NOT NULL,
|
|
locked BOOLEAN NOT NULL,
|
|
locked_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE TABLE "invite_lock" (
|
|
user_id BIGINT REFERENCES account(id) PRIMARY KEY NOT NULL,
|
|
locked BOOLEAN NOT NULL,
|
|
locked_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE TABLE "notification" (
|
|
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
title TEXT NOT NULL,
|
|
sent_to BIGINT REFERENCES account(id) NOT NULL,
|
|
sent_at TIMESTAMPTZ NOT NULL,
|
|
read BOOLEAN NOT NULL,
|
|
|
|
-- complex payload stuff: done at typescript time.
|
|
kind TEXT NOT NULL CHECK (kind IN ('rivaled_by', 'quest_changed', 'site_announcement')),
|
|
payload JSONB NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "bms_course_lookup" (
|
|
-- TODO(zk): This makes the false assertion that only one
|
|
-- course may exist with a given set of md5s -- i.e. no one
|
|
-- else will make another course with the same charts in the
|
|
-- same order. This is not correct, and might bite us in the
|
|
-- future.
|
|
--
|
|
-- This data is sourced from seeds at the moment.
|
|
md5sums TEXT PRIMARY KEY NOT NULL CHECK (
|
|
md5sums ~ '^[0-9a-f]+$' AND char_length(md5sums) % 32 = 0
|
|
),
|
|
title TEXT NOT NULL UNIQUE,
|
|
set TEXT NOT NULL,
|
|
game GAME NOT NULL,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "folder" (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
legacy_id TEXT UNIQUE NOT NULL,
|
|
game GAME NOT NULL,
|
|
inactive BOOLEAN NOT NULL,
|
|
title TEXT NOT NULL,
|
|
|
|
-- Used in URLs. should be short, but must be unique per game!
|
|
slug TEXT NOT NULL,
|
|
|
|
-- SQL predicate (no leading WHERE) for charts in this folder; matches seeds `where`.
|
|
-- Quoted: "where" is a reserved word in SQL.
|
|
"where" TEXT NOT NULL,
|
|
|
|
-- NULL means no version restriction.
|
|
version_filter TEXT[],
|
|
search_terms TEXT[] NOT NULL
|
|
);
|
|
CREATE UNIQUE INDEX folder_unique_slug_game ON "folder" (game, slug);
|
|
|
|
CREATE TABLE "table" ( -- heh
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
legacy_id TEXT UNIQUE NOT NULL,
|
|
game GAME NOT NULL,
|
|
inactive BOOLEAN NOT NULL,
|
|
title TEXT NOT NULL,
|
|
default_value BOOLEAN NOT NULL,
|
|
|
|
-- Used in URLs. should be short, but must be unique per game!
|
|
slug TEXT
|
|
);
|
|
CREATE UNIQUE INDEX table_unique_slug_game ON "table" (game, slug) WHERE slug IS NOT NULL;
|
|
|
|
-- Partial unique index: at most one default table per game.
|
|
CREATE UNIQUE INDEX one_default_table_per_game ON "table" (game) WHERE default_value = true;
|
|
|
|
CREATE TABLE "table_folder" (
|
|
table_id TEXT REFERENCES "table"(id) NOT NULL,
|
|
folder_id TEXT REFERENCES folder(id) NOT NULL,
|
|
-- Order within the table; matches the `folders` array index in `tables.json`.
|
|
ordering INTEGER NOT NULL,
|
|
|
|
PRIMARY KEY (table_id, folder_id)
|
|
);
|
|
|
|
-- notable difference from Tachi2; all songs and charts are in
|
|
-- one table and not split across N tables
|
|
-- no real reason for this ever to have _not_ been the case
|
|
-- as it just moves "query" logic to typescript level logic
|
|
-- for no benefit.
|
|
CREATE TABLE "song" (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
legacy_id INT NOT NULL,
|
|
game_group GAME_GROUP NOT NULL,
|
|
|
|
UNIQUE (game_group, legacy_id),
|
|
|
|
title TEXT NOT NULL,
|
|
artist TEXT NOT NULL,
|
|
|
|
search_terms TEXT[] NOT NULL,
|
|
alt_titles TEXT[] NOT NULL,
|
|
|
|
-- Denormalized search_term + alt_title text for FTS (kept in sync with seeds / triggers).
|
|
fts_document TEXT NOT NULL DEFAULT '',
|
|
textsearch tsvector NOT NULL GENERATED ALWAYS AS (
|
|
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
|
|
setweight(to_tsvector('simple', coalesce(artist, '')), 'B') ||
|
|
setweight(to_tsvector('simple', coalesce(fts_document, '')), 'C')
|
|
) STORED,
|
|
data JSONB NOT NULL -- game specific payload
|
|
);
|
|
|
|
-- Populate fts_document from search_terms / alt_titles (no-op on empty DB; seeds also set this column).
|
|
UPDATE song AS s
|
|
SET fts_document = trim(
|
|
both ' ' FROM concat_ws(
|
|
' ',
|
|
coalesce(array_to_string(s.search_terms, ' '), ''),
|
|
coalesce(array_to_string(s.alt_titles, ' '), '')
|
|
)
|
|
);
|
|
|
|
CREATE INDEX song_textsearch_gin ON song USING GIN (textsearch);
|
|
|
|
CREATE INDEX song_title_trgm ON song USING GIN (title gin_trgm_ops);
|
|
|
|
CREATE INDEX song_artist_trgm ON song USING GIN (artist gin_trgm_ops);
|
|
|
|
CREATE INDEX song_fts_document_trgm ON song USING GIN (fts_document gin_trgm_ops);
|
|
|
|
CREATE TABLE "chart" (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
legacy_id TEXT UNIQUE NOT NULL,
|
|
game GAME NOT NULL,
|
|
song_id TEXT REFERENCES song(id) NOT NULL,
|
|
|
|
level TEXT NOT NULL,
|
|
level_num FLOAT8 NOT NULL,
|
|
|
|
is_primary BOOLEAN NOT NULL,
|
|
difficulty TEXT NOT NULL,
|
|
|
|
versions TEXT[] NOT NULL,
|
|
|
|
data JSONB NOT NULL, -- game specific payload
|
|
|
|
-- SHA-256 hex digest of chart fields that feed scoreDeriver/scoreCalcs. When this
|
|
-- changes, scores on the chart need re-derivation.
|
|
derivation_checksum TEXT
|
|
);
|
|
|
|
-- Denormalized chart → folders cache (rebuilt by app; see rebuildFolderChartLookup).
|
|
CREATE TABLE "folder_chart_lookup" (
|
|
folder_id TEXT NOT NULL REFERENCES folder(id) ON DELETE CASCADE,
|
|
chart_id TEXT NOT NULL REFERENCES chart(id) ON DELETE CASCADE,
|
|
|
|
PRIMARY KEY (folder_id, chart_id)
|
|
);
|
|
CREATE INDEX ON "folder_chart_lookup" (folder_id);
|
|
CREATE INDEX ON "folder_chart_lookup" (chart_id);
|
|
|
|
CREATE TABLE "game_settings" (
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
game GAME NOT NULL,
|
|
PRIMARY KEY (user_id, game),
|
|
|
|
pf_preferred_score_alg TEXT,
|
|
pf_preferred_session_alg TEXT,
|
|
pf_preferred_profile_alg TEXT,
|
|
pf_preferred_default_enum TEXT,
|
|
pf_default_table TEXT,
|
|
pf_preferred_ranking TEXT CHECK (pf_preferred_ranking IN ('global', 'rival')),
|
|
|
|
data JSONB NOT NULL -- game specific payload
|
|
);
|
|
|
|
CREATE TABLE "game_rival" (
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
game GAME NOT NULL,
|
|
rival BIGINT REFERENCES account(id) NOT NULL,
|
|
|
|
PRIMARY KEY (user_id, game, rival),
|
|
|
|
CHECK (user_id != rival)
|
|
);
|
|
|
|
CREATE TABLE "game_settings_showcase" (
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
game GAME NOT NULL,
|
|
|
|
PRIMARY KEY (user_id, game),
|
|
|
|
-- kinda ridiculous but { mode: "chart", etc. } or
|
|
-- { mode: "folder" }
|
|
data JSONB NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "game_profile" (
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
game GAME NOT NULL,
|
|
|
|
PRIMARY KEY (user_id, game),
|
|
|
|
ratings JSONB NOT NULL,
|
|
classes JSONB NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "game_stats_snapshot" (
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
game GAME NOT NULL,
|
|
timestamp TIMESTAMPTZ NOT NULL,
|
|
|
|
PRIMARY KEY (user_id, game, timestamp),
|
|
|
|
playcount BIGINT NOT NULL,
|
|
|
|
-- Record<string, number>
|
|
ratings JSONB NOT NULL,
|
|
-- Record<string, string>
|
|
classes JSONB NOT NULL,
|
|
-- Record<string, {outOf: integer, ranking: integer}>
|
|
rankings JSONB NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "class_achievement" (
|
|
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
|
|
game GAME NOT NULL,
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
|
|
class_set TEXT NOT NULL,
|
|
class_prev_value TEXT NOT NULL,
|
|
class_value TEXT NOT NULL,
|
|
|
|
timestamp TIMESTAMPTZ NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "session" (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
game GAME NOT NULL,
|
|
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
|
|
time_inserted TIMESTAMPTZ NOT NULL,
|
|
time_started TIMESTAMPTZ NOT NULL,
|
|
time_ended TIMESTAMPTZ NOT NULL,
|
|
|
|
calculated_data JSONB NOT NULL,
|
|
highlight BOOLEAN NOT NULL,
|
|
|
|
-- Session UGPT search: FTS + trgm (see lib/search/session-search.ts).
|
|
textsearch tsvector NOT NULL GENERATED ALWAYS AS (
|
|
setweight(to_tsvector('simple', coalesce(name, '')), 'A') ||
|
|
setweight(to_tsvector('simple', coalesce(description, '')), 'B')
|
|
) STORED
|
|
);
|
|
|
|
CREATE TABLE "import" (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
|
|
time_started TIMESTAMPTZ NOT NULL,
|
|
time_finished TIMESTAMPTZ NOT NULL,
|
|
game_group GAME_GROUP NOT NULL,
|
|
|
|
import_type IMPORT_TYPE NOT NULL,
|
|
user_intent BOOLEAN NOT NULL,
|
|
|
|
service TEXT NOT NULL
|
|
);
|
|
|
|
-- Some import methods import for multiple games
|
|
-- but importantly they're always in the same game group
|
|
-- this is a random tachi2 holdover.
|
|
CREATE TABLE "import_game" (
|
|
id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
|
|
game GAME NOT NULL,
|
|
|
|
PRIMARY KEY (id, game)
|
|
);
|
|
|
|
CREATE TABLE "import_error" (
|
|
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
|
|
import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
|
|
type TEXT NOT NULL,
|
|
message TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "import_session" (
|
|
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
|
|
import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
|
|
session_id TEXT REFERENCES session(id) NOT NULL,
|
|
type TEXT NOT NULL CHECK (type IN ('appended', 'created')),
|
|
|
|
UNIQUE (import_id, session_id)
|
|
);
|
|
|
|
CREATE TABLE "import_class" (
|
|
row_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
|
|
import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
|
|
game GAME NOT NULL,
|
|
set TEXT NOT NULL,
|
|
prev TEXT,
|
|
new TEXT NOT NULL
|
|
);
|
|
|
|
|
|
CREATE TABLE "import_timing" (
|
|
id TEXT PRIMARY KEY REFERENCES import(id) ON DELETE CASCADE NOT NULL,
|
|
timestamp TIMESTAMPTZ NOT NULL,
|
|
|
|
import_secs_avg FLOAT8 NOT NULL,
|
|
import_parse_secs_avg FLOAT8 NOT NULL,
|
|
pb_secs_avg FLOAT8 NOT NULL,
|
|
session_secs_avg FLOAT8 NOT NULL,
|
|
|
|
parse_secs FLOAT8 NOT NULL,
|
|
import_secs FLOAT8 NOT NULL,
|
|
import_parse_secs FLOAT8 NOT NULL,
|
|
session_secs FLOAT8 NOT NULL,
|
|
pb_secs FLOAT8 NOT NULL,
|
|
ugs_secs FLOAT8 NOT NULL,
|
|
goal_secs FLOAT8 NOT NULL,
|
|
quest_secs FLOAT8 NOT NULL,
|
|
|
|
-- not actually a sum of the above fields; just import.end - import.start
|
|
total_secs FLOAT8 NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "score" (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
|
|
-- TODO(zk): Redundant? joins on session(id).
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
chart_id TEXT REFERENCES chart(id) NOT NULL,
|
|
|
|
game GAME NOT NULL,
|
|
|
|
-- TODO(zk): FK references are disabled here
|
|
-- because some scores point to sessions that
|
|
-- never happened!
|
|
--
|
|
-- Also this is allowed to be null, because
|
|
-- some older scores don't belong to sessions
|
|
-- or imports. It's not good. A future migration
|
|
-- will unfuck this.
|
|
session_id TEXT,
|
|
import_id TEXT,
|
|
|
|
-- what would be { ...providedMetrics, ...optionalMetrics } in Mongo
|
|
data JSONB NOT NULL,
|
|
-- f(chart, score.data) => score.derived_data
|
|
derived_data JSONB NOT NULL,
|
|
-- f(chart, score.data) => score.calculated_data
|
|
calculated_data JSONB NOT NULL,
|
|
-- What was "judgements" in Mongo
|
|
judgements JSONB NOT NULL,
|
|
|
|
meta JSONB NOT NULL,
|
|
|
|
time_achieved TIMESTAMPTZ,
|
|
time_added TIMESTAMPTZ NOT NULL,
|
|
|
|
highlight BOOLEAN NOT NULL,
|
|
comment TEXT,
|
|
|
|
-- Staging: false until post-import steps finish; then true (see score_import_uncommitted_idx).
|
|
committed BOOLEAN NOT NULL DEFAULT TRUE
|
|
);
|
|
|
|
-- Mark here when PBs become dirty and need to be recalculated.
|
|
CREATE TABLE pb_dirty (
|
|
user_id BIGINT NOT NULL,
|
|
chart_id TEXT NOT NULL,
|
|
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (user_id, chart_id)
|
|
);
|
|
|
|
-- Mark here when scores become dirty and need to be recalculated.
|
|
CREATE TABLE "score_rederive" (
|
|
chart_id TEXT NOT NULL PRIMARY KEY,
|
|
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- Trigger function: on any score INSERT/UPDATE/DELETE, mark the (user_id, chart_id)
|
|
-- pair as needing PB recalculation.
|
|
CREATE FUNCTION enqueue_pb_dirty() RETURNS trigger AS $$
|
|
BEGIN
|
|
IF TG_OP = 'DELETE' THEN
|
|
INSERT INTO pb_dirty (user_id, chart_id)
|
|
VALUES (OLD.user_id, OLD.chart_id)
|
|
ON CONFLICT DO NOTHING;
|
|
ELSE
|
|
INSERT INTO pb_dirty (user_id, chart_id)
|
|
VALUES (NEW.user_id, NEW.chart_id)
|
|
ON CONFLICT DO NOTHING;
|
|
END IF;
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER "score_pb_dirty"
|
|
AFTER INSERT OR UPDATE OR DELETE ON score
|
|
FOR EACH ROW EXECUTE FUNCTION enqueue_pb_dirty();
|
|
|
|
-- Trigger function: on chart UPDATE, if derivation_checksum changed, enqueue the chart
|
|
-- for score re-derivation.
|
|
CREATE FUNCTION enqueue_score_rederive() RETURNS trigger AS $$
|
|
BEGIN
|
|
IF OLD.derivation_checksum IS DISTINCT FROM NEW.derivation_checksum THEN
|
|
INSERT INTO score_rederive (chart_id)
|
|
VALUES (NEW.id)
|
|
ON CONFLICT DO NOTHING;
|
|
END IF;
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER "chart_score_rederive"
|
|
AFTER UPDATE ON chart
|
|
FOR EACH ROW EXECUTE FUNCTION enqueue_score_rederive();
|
|
|
|
CREATE TABLE "pb" (
|
|
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
|
|
user_id BIGINT NOT NULL REFERENCES account(id),
|
|
chart_id TEXT NOT NULL REFERENCES chart(id),
|
|
|
|
-- for "Profile views", where the PB is
|
|
-- calculated from a subset of the user's
|
|
-- scores.
|
|
lens TEXT,
|
|
|
|
UNIQUE (user_id, chart_id, lens),
|
|
|
|
-- all data is f(scores) => pb
|
|
data JSONB NOT NULL,
|
|
derived_data JSONB NOT NULL,
|
|
calculated_data JSONB NOT NULL,
|
|
judgements JSONB NOT NULL,
|
|
|
|
-- how to rank scores. Five (sorry, hardcoded) additional
|
|
-- tiebreakers are available.
|
|
ranking_value FLOAT8 NOT NULL,
|
|
ranking_value_tb1 FLOAT8,
|
|
ranking_value_tb2 FLOAT8,
|
|
ranking_value_tb3 FLOAT8,
|
|
ranking_value_tb4 FLOAT8,
|
|
ranking_value_tb5 FLOAT8,
|
|
|
|
-- needless bullshit manually copied from scores
|
|
-- it would be nice to figure this out with a join instead
|
|
-- but too complex.
|
|
highlight BOOLEAN NOT NULL,
|
|
-- MAX(score.time_achieved)
|
|
time_achieved TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE TABLE "pb_composed_from" (
|
|
pb_id UUID REFERENCES pb(row_id) NOT NULL,
|
|
score_id TEXT REFERENCES score(id) NOT NULL,
|
|
|
|
PRIMARY KEY (pb_id, score_id)
|
|
);
|
|
|
|
-- Global chart rank/outOf live in one place: the leaderboard window over `pb`,
|
|
-- not duplicated into `calculated_data`.
|
|
CREATE VIEW "chart_leaderboard" AS
|
|
SELECT
|
|
pb.*,
|
|
RANK() OVER (
|
|
PARTITION BY chart_id, lens
|
|
ORDER BY
|
|
ranking_value DESC NULLS LAST,
|
|
ranking_value_tb1 DESC NULLS LAST,
|
|
ranking_value_tb2 DESC NULLS LAST,
|
|
ranking_value_tb3 DESC NULLS LAST,
|
|
ranking_value_tb4 DESC NULLS LAST,
|
|
ranking_value_tb5 DESC NULLS LAST,
|
|
time_achieved ASC NULLS LAST
|
|
) AS rank,
|
|
COUNT(*) OVER (PARTITION BY chart_id, lens) AS out_of
|
|
FROM pb;
|
|
|
|
|
|
CREATE TABLE "orphan_score" (
|
|
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
import_id TEXT REFERENCES import(id) ON DELETE SET NULL,
|
|
|
|
orphan_id TEXT NOT NULL,
|
|
import_type IMPORT_TYPE NOT NULL,
|
|
game_group GAME_GROUP NOT NULL,
|
|
|
|
data JSONB NOT NULL,
|
|
context JSONB NOT NULL,
|
|
time_inserted TIMESTAMPTZ NOT NULL,
|
|
|
|
error_message TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "score_blacklist" (
|
|
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
score_id TEXT NOT NULL,
|
|
|
|
UNIQUE (user_id, score_id)
|
|
);
|
|
|
|
CREATE TABLE "folder_view" (
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
folder_id TEXT REFERENCES folder(id) ON UPDATE CASCADE NOT NULL,
|
|
|
|
PRIMARY KEY (user_id, folder_id),
|
|
|
|
last_viewed TIMESTAMPTZ NOT NULL
|
|
);
|
|
|
|
|
|
-- orphan-chart-queue. Used for BMS to de-orphan charts.
|
|
CREATE TABLE "orphan_chart" (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
game GAME NOT NULL,
|
|
|
|
-- full ChartDocument + SongDocument blobs, validated on re-import
|
|
chart_doc JSONB NOT NULL,
|
|
song_doc JSONB NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "orphan_chart_user" (
|
|
orphan_chart_id TEXT REFERENCES orphan_chart(id) NOT NULL,
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
|
|
PRIMARY KEY (orphan_chart_id, user_id)
|
|
);
|
|
|
|
-- import-trackers (ephemeral; removed on success, kept on failure)
|
|
CREATE TABLE "import_tracker" (
|
|
import_id TEXT PRIMARY KEY NOT NULL,
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
import_type IMPORT_TYPE NOT NULL,
|
|
user_intent BOOLEAN NOT NULL,
|
|
time_started TIMESTAMPTZ NOT NULL,
|
|
|
|
-- NULL = ONGOING, non-NULL = FAILED
|
|
error JSONB
|
|
);
|
|
|
|
|
|
-- goals
|
|
|
|
-- TODO(zk): A lot of this shit can just be completely ripped out
|
|
-- it's a terrible design decision and data duplication abound
|
|
-- everywhere.
|
|
--
|
|
-- Maybe just rewrite goals/quests!!
|
|
|
|
CREATE TABLE "goal" (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
game GAME NOT NULL,
|
|
name TEXT NOT NULL,
|
|
|
|
-- "single" | "multi" | "folder" + associated chart data
|
|
charts JSONB NOT NULL,
|
|
-- criteria: {mode, key, value, ...}
|
|
criteria JSONB NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "goal_sub" (
|
|
goal_id TEXT REFERENCES goal(id) NOT NULL,
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
|
|
PRIMARY KEY (goal_id, user_id),
|
|
|
|
last_interaction TIMESTAMPTZ,
|
|
progress FLOAT8,
|
|
progress_human TEXT NOT NULL,
|
|
out_of FLOAT8 NOT NULL,
|
|
out_of_human TEXT NOT NULL,
|
|
|
|
achieved BOOLEAN NOT NULL,
|
|
time_achieved TIMESTAMPTZ,
|
|
was_instantly_achieved BOOLEAN NOT NULL,
|
|
was_assigned_standalone BOOLEAN NOT NULL
|
|
);
|
|
|
|
-- quests
|
|
CREATE TABLE "quest" (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
game GAME NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
|
|
-- Array<QuestSection> — sections with titles, descs, and goal references.
|
|
-- Too structured/variable to flatten usefully.
|
|
quest_data JSONB NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "quest_sub" (
|
|
quest_id TEXT REFERENCES quest(id) NOT NULL,
|
|
user_id BIGINT REFERENCES account(id) NOT NULL,
|
|
|
|
PRIMARY KEY (quest_id, user_id),
|
|
|
|
progress INT NOT NULL,
|
|
last_interaction TIMESTAMPTZ,
|
|
achieved BOOLEAN NOT NULL,
|
|
time_achieved TIMESTAMPTZ,
|
|
was_instantly_achieved BOOLEAN NOT NULL
|
|
);
|
|
|
|
-- questlines
|
|
CREATE TABLE "questline" (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
game GAME NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "questline_quest" (
|
|
questline_id TEXT REFERENCES questline(id) NOT NULL,
|
|
quest_id TEXT REFERENCES quest(id) NOT NULL,
|
|
sort_order INT NOT NULL,
|
|
|
|
PRIMARY KEY (questline_id, quest_id)
|
|
);
|
|
|
|
-- TODO(zk): This should really be like, "goal_activity" in my
|
|
-- opinion.
|
|
|
|
CREATE TABLE "import_quest" (
|
|
row_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
|
|
import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
|
|
quest_id TEXT REFERENCES quest(id) NOT NULL,
|
|
|
|
prev_achieved BOOLEAN NOT NULL,
|
|
prev_progress INTEGER NOT NULL,
|
|
|
|
new_achieved BOOLEAN NOT NULL,
|
|
new_progress INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE "import_goal" (
|
|
row_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
|
|
import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL,
|
|
goal_id TEXT REFERENCES goal(id) NOT NULL,
|
|
|
|
prev_achieved BOOLEAN NOT NULL,
|
|
prev_out_of INTEGER NOT NULL,
|
|
prev_out_of_human TEXT NOT NULL,
|
|
prev_progress INTEGER,
|
|
prev_progress_human TEXT NOT NULL,
|
|
|
|
new_achieved BOOLEAN NOT NULL,
|
|
new_out_of INTEGER NOT NULL,
|
|
new_out_of_human TEXT NOT NULL,
|
|
new_progress INTEGER,
|
|
new_progress_human TEXT NOT NULL
|
|
);
|
|
|
|
-- ==> Indexes
|
|
|
|
-- score (15M rows)
|
|
-- PB construction: "all scores for user X on chart Y", score dedup on import.
|
|
-- The hottest path in the whole import pipeline.
|
|
CREATE INDEX score_user_chart_idx ON score (user_id, chart_id);
|
|
|
|
-- Recent scores feed and "most recent play" endpoints. user_id first so
|
|
-- the planner can filter to one user before sorting.
|
|
CREATE INDEX score_user_recent_idx ON score (user_id, time_added DESC);
|
|
|
|
-- Highlights feed and activity. Partial: only the rows that matter.
|
|
CREATE INDEX score_user_highlights_idx ON score (user_id, time_added DESC)
|
|
WHERE highlight = true;
|
|
|
|
-- "All scores on chart X" — chart pages, playcount, import duplicate check.
|
|
-- Needed separately because score_user_chart_idx has user_id as the left column.
|
|
CREATE INDEX score_chart_idx ON score (chart_id);
|
|
|
|
-- Failed-import cleanup: delete uncommitted rows by import_id.
|
|
CREATE INDEX score_import_uncommitted_idx ON score (import_id)
|
|
WHERE
|
|
committed = FALSE;
|
|
|
|
CREATE UNIQUE INDEX orphan_score_orphan_id_key ON orphan_score (orphan_id);
|
|
|
|
-- pb (5M rows) — hottest table
|
|
-- The single most important index in the schema.
|
|
-- Serves: chart_leaderboard view, leaderboard pagination, and the COUNT rank
|
|
-- queries ("how many PBs beat mine?"). chart_id partitions the 5M rows into
|
|
-- per-chart buckets; ranking columns give the sort order for free.
|
|
CREATE INDEX pb_leaderboard_idx ON pb (
|
|
chart_id,
|
|
ranking_value DESC NULLS LAST,
|
|
ranking_value_tb1 DESC NULLS LAST,
|
|
ranking_value_tb2 DESC NULLS LAST,
|
|
ranking_value_tb3 DESC NULLS LAST,
|
|
ranking_value_tb4 DESC NULLS LAST,
|
|
ranking_value_tb5 DESC NULLS LAST,
|
|
time_achieved ASC NULLS LAST
|
|
);
|
|
|
|
-- Reverse lookup: "which PB does this score contribute to?"
|
|
-- Used when deleting a score to find its PB for recomputation.
|
|
-- The PK (pb_id, score_id) can't serve score_id-first lookups.
|
|
CREATE INDEX pb_composed_from_score_idx ON pb_composed_from (score_id);
|
|
|
|
-- session
|
|
-- UGPT session history ("show me user X's IIDX sessions, newest first").
|
|
-- game encodes both game+playtype in the new schema.
|
|
CREATE INDEX session_user_game_idx ON session (user_id, game, time_started DESC);
|
|
|
|
-- Global game activity feed ("recent sessions for IIDX globally").
|
|
CREATE INDEX session_game_recent_idx ON session (game, time_started DESC);
|
|
|
|
CREATE INDEX session_textsearch_gin ON session USING GIN (textsearch);
|
|
|
|
CREATE INDEX session_name_trgm ON session USING GIN (name gin_trgm_ops);
|
|
|
|
CREATE INDEX session_description_trgm ON session USING GIN (description gin_trgm_ops);
|
|
|
|
-- import
|
|
-- User's import history page. time_started DESC for newest-first ordering.
|
|
CREATE INDEX import_user_idx ON import (user_id, time_started DESC);
|
|
|
|
-- import_timing
|
|
-- Ported from Mongo. Admin/analytics queries filtering by timestamp range.
|
|
CREATE INDEX import_timing_timestamp_idx ON import_timing (timestamp);
|
|
|
|
-- notification
|
|
-- Notification inbox: "show sent_to=X's notifications, newest first".
|
|
-- Also serves unread-count query (WHERE read = false) via partial scan.
|
|
CREATE INDEX notification_inbox_idx ON notification (sent_to, read, sent_at DESC);
|
|
|
|
-- class_achievement
|
|
-- User's class history per game. Ported from Mongo's game+playtype+timeAchieved.
|
|
CREATE INDEX class_achievement_user_idx ON class_achievement (user_id, game, timestamp DESC);
|
|
|
|
-- Global recent class achievements (site-wide activity feed).
|
|
CREATE INDEX class_achievement_recent_idx ON class_achievement (game, timestamp DESC);
|
|
|
|
-- goal_sub / quest_sub
|
|
-- "All goals for user X". The PK is (goal_id, user_id) so user_id alone
|
|
-- has no usable left-prefix index. achieved included for cheap pending-goal scans.
|
|
CREATE INDEX goal_sub_user_idx ON goal_sub (user_id, achieved);
|
|
|
|
-- Same reason as above for quests.
|
|
CREATE INDEX quest_sub_user_idx ON quest_sub (user_id, achieved);
|
|
|
|
-- folder_view
|
|
-- "Recently viewed folders for user X", newest first. Drives the sidebar.
|
|
CREATE INDEX folder_view_user_idx ON folder_view (user_id, last_viewed DESC);
|
|
|
|
-- game_stats_snapshot
|
|
-- Historical snapshot queries by game+time (e.g. "rating history for all
|
|
-- IIDX players at a given timestamp"). PK is (user_id, game, timestamp),
|
|
-- so game-first lookups need their own index.
|
|
CREATE INDEX game_stats_snapshot_game_idx ON game_stats_snapshot (game, timestamp DESC);
|
|
|
|
-- chart — general
|
|
-- "All charts for song X" — song pages, chart listing. Postgres does NOT
|
|
-- auto-index FK columns, so this needs to be explicit.
|
|
CREATE INDEX chart_song_idx ON chart (song_id);
|
|
|
|
-- Folder membership (`BuildFolderQuery`): seeds often use `chart.level` / `chart.level_num`
|
|
-- without `chart.game`. Folder rows are per-game, so the app ANDs `chart.game = folder.game`;
|
|
-- these indexes avoid scanning all games' charts (~260k rows).
|
|
CREATE INDEX chart_game_level_idx ON chart (game, level);
|
|
CREATE INDEX chart_game_level_num_idx ON chart (game, level_num);
|
|
|
|
-- Primary chart lookup by (song, difficulty): score import and routing.
|
|
-- Partial: only index primary charts since those are what's looked up 99% of the time.
|
|
CREATE UNIQUE INDEX chart_primary_song_difficulty_idx
|
|
ON chart (game, song_id, difficulty)
|
|
WHERE is_primary = true;
|
|
|
|
-- chart — game-specific JSONB expression indexes
|
|
-- Partial expression indexes mirroring the per-game Mongo collections.
|
|
-- Used during score import to look up charts by their game-native ID or hash.
|
|
|
|
-- BMS: imported by MD5 and SHA256 hash. Both playtypes share an ID space.
|
|
CREATE UNIQUE INDEX chart_bms_7k_md5_idx ON chart ((data->>'hashMD5')) WHERE game = 'bms-7k';
|
|
CREATE UNIQUE INDEX chart_bms_14k_md5_idx ON chart ((data->>'hashMD5')) WHERE game = 'bms-14k';
|
|
CREATE UNIQUE INDEX chart_bms_7k_sha_idx ON chart ((data->>'hashSHA256')) WHERE game = 'bms-7k';
|
|
CREATE UNIQUE INDEX chart_bms_14k_sha_idx ON chart ((data->>'hashSHA256')) WHERE game = 'bms-14k';
|
|
|
|
-- PMS: same as BMS, two controller modes share ID space.
|
|
CREATE UNIQUE INDEX chart_pms_ctrl_md5_idx ON chart ((data->>'hashMD5')) WHERE game = 'pms-controller';
|
|
CREATE UNIQUE INDEX chart_pms_kb_md5_idx ON chart ((data->>'hashMD5')) WHERE game = 'pms-keyboard';
|
|
CREATE UNIQUE INDEX chart_pms_ctrl_sha_idx ON chart ((data->>'hashSHA256')) WHERE game = 'pms-controller';
|
|
CREATE UNIQUE INDEX chart_pms_kb_sha_idx ON chart ((data->>'hashSHA256')) WHERE game = 'pms-keyboard';
|
|
|
|
-- USC: SHA1 hash, two controller modes.
|
|
CREATE UNIQUE INDEX chart_usc_ctrl_sha1_idx ON chart ((data->>'hashSHA1')) WHERE game = 'usc-controller';
|
|
CREATE UNIQUE INDEX chart_usc_kb_sha1_idx ON chart ((data->>'hashSHA1')) WHERE game = 'usc-keyboard';
|
|
|
|
-- IIDX: SHA256 non-unique (same chart can have multiple hashes across versions).
|
|
CREATE INDEX chart_iidx_sp_sha_idx ON chart ((data->>'hashSHA256')) WHERE game = 'iidx-sp';
|
|
CREATE INDEX chart_iidx_dp_sha_idx ON chart ((data->>'hashSHA256')) WHERE game = 'iidx-dp';
|
|
|
|
-- popn: SHA256 unique.
|
|
CREATE UNIQUE INDEX chart_popn_sha_idx ON chart ((data->>'hashSHA256')) WHERE game = 'popn';
|
|
|
|
-- IIDX: inGameID lookups. SP and DP have separate game values so indexed separately.
|
|
CREATE INDEX chart_iidx_sp_ingameid_idx
|
|
ON chart ((data->>'inGameID'), difficulty) WHERE game = 'iidx-sp';
|
|
CREATE INDEX chart_iidx_dp_ingameid_idx
|
|
ON chart ((data->>'inGameID'), difficulty) WHERE game = 'iidx-dp';
|
|
|
|
-- The following are all unique: one chart per (inGameID, difficulty) per game.
|
|
CREATE UNIQUE INDEX chart_sdvx_ingameid_idx
|
|
ON chart ((data->>'inGameID'), difficulty) WHERE game = 'sdvx';
|
|
CREATE UNIQUE INDEX chart_museca_ingameid_idx
|
|
ON chart ((data->>'inGameID'), difficulty) WHERE game = 'museca';
|
|
CREATE UNIQUE INDEX chart_chunithm_ingameid_idx
|
|
ON chart ((data->>'inGameID'), difficulty) WHERE game = 'chunithm';
|
|
CREATE UNIQUE INDEX chart_gitadora_gita_ingameid_idx
|
|
ON chart ((data->>'inGameID'), difficulty) WHERE game = 'gitadora-gita';
|
|
CREATE UNIQUE INDEX chart_gitadora_dora_ingameid_idx
|
|
ON chart ((data->>'inGameID'), difficulty) WHERE game = 'gitadora-dora';
|
|
CREATE UNIQUE INDEX chart_wacca_ingameid_idx
|
|
ON chart ((data->>'inGameID'), difficulty) WHERE game = 'wacca';
|
|
CREATE UNIQUE INDEX chart_jubeat_ingameid_idx
|
|
ON chart ((data->>'inGameID'), difficulty) WHERE game = 'jubeat';
|
|
CREATE UNIQUE INDEX chart_maimai_ingameid_idx
|
|
ON chart ((data->>'inGameID'), difficulty) WHERE game = 'maimai';
|
|
CREATE UNIQUE INDEX chart_maimaidx_ingameid_idx
|
|
ON chart ((data->>'inGameID'), difficulty) WHERE game = 'maimaidx';
|
|
|
|
-- maimai/maimaidx also looked up by string ID.
|
|
CREATE UNIQUE INDEX chart_maimai_ingamestr_idx
|
|
ON chart ((data->>'inGameStrID'), difficulty) WHERE game = 'maimai';
|
|
CREATE UNIQUE INDEX chart_maimaidx_ingamestr_idx
|
|
ON chart ((data->>'inGameStrID'), difficulty) WHERE game = 'maimaidx';
|
|
|
|
-- <== End indexes
|
|
|
|
-- ==> Bot: discord-user-map
|
|
-- Maps Discord user IDs to Tachi accounts for the Discord bot integration.
|
|
-- Stored in private namespace because it contains API tokens.
|
|
CREATE TABLE "priv_discord_user_map" (
|
|
user_id BIGINT REFERENCES account(id) ON DELETE CASCADE NOT NULL,
|
|
discord_id TEXT UNIQUE NOT NULL,
|
|
api_token TEXT REFERENCES priv_api_token(token) ON DELETE CASCADE NOT NULL
|
|
);
|
|
|
|
CREATE UNIQUE INDEX priv_discord_user_map_user_id_idx ON priv_discord_user_map (user_id);
|
|
-- <== End bot |