-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathschema.sql
More file actions
633 lines (546 loc) · 26.3 KB
/
Copy pathschema.sql
File metadata and controls
633 lines (546 loc) · 26.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
-- Unison: Crowdsourced Lyrics Database Schema
-- Designed for PostgreSQL
-- Public keys table (cryptographic identity)
CREATE TABLE IF NOT EXISTS public_keys (
key_id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER)
);
-- Users table (identity + reputation)
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
key_id TEXT UNIQUE NOT NULL,
reputation DOUBLE PRECISION DEFAULT 1.0,
vote_count INTEGER DEFAULT 0,
avg_vote DOUBLE PRECISION DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER)
);
CREATE INDEX IF NOT EXISTS idx_users_key_id ON users(key_id);
-- User-set nickname (overrides generated petname)
ALTER TABLE users ADD COLUMN IF NOT EXISTS nickname TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS nickname_lower TEXT
GENERATED ALWAYS AS (LOWER(nickname)) STORED;
ALTER TABLE users ADD COLUMN IF NOT EXISTS nickname_updated_at INTEGER;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_nickname_lower
ON users(nickname_lower) WHERE nickname_lower IS NOT NULL;
-- Profile picture choice (NULL avatar_type = generated default)
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_type TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_ref TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_updated_at INTEGER;
-- Discord account links (Discord user <-> Better Lyrics key)
CREATE TABLE IF NOT EXISTS discord_links (
discord_id TEXT PRIMARY KEY,
key_id TEXT UNIQUE NOT NULL,
discord_username TEXT,
linked_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER)
);
ALTER TABLE discord_links ADD COLUMN IF NOT EXISTS discord_avatar TEXT;
CREATE INDEX IF NOT EXISTS idx_discord_links_key_id ON discord_links(key_id);
-- Main lyrics table
CREATE TABLE IF NOT EXISTS lyrics (
id SERIAL PRIMARY KEY,
-- YouTube video identifier (primary lookup key)
video_id TEXT NOT NULL,
-- Metadata for display
song TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
isrc TEXT, -- International Standard Recording Code
duration INTEGER NOT NULL, -- in seconds
-- Normalized values for search (lowercase, stripped)
song_norm TEXT NOT NULL,
artist_norm TEXT NOT NULL,
-- Lyrics content (gzip compressed, base64 encoded)
lyrics TEXT NOT NULL,
format TEXT CHECK(format IN ('ttml', 'lrc', 'plain')) NOT NULL DEFAULT 'lrc',
-- Metadata
language TEXT,
sync_type TEXT CHECK(sync_type IN ('richsync', 'linesync', 'plain')) NOT NULL DEFAULT 'linesync',
-- Quality metrics (raw counts)
score INTEGER DEFAULT 0,
upvotes INTEGER DEFAULT 0,
downvotes INTEGER DEFAULT 0,
-- Reputation-weighted metrics (computed by batch job)
effective_score DOUBLE PRECISION DEFAULT 0,
vote_count INTEGER DEFAULT 0,
diversity_bonus INTEGER DEFAULT 0,
confidence TEXT CHECK(confidence IN ('low', 'medium', 'high')) DEFAULT 'low',
score_updated_at INTEGER,
-- Timestamps
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
updated_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
-- Submitter info
submitter_id INTEGER REFERENCES users(id)
);
-- Indexes for lookups
CREATE INDEX IF NOT EXISTS idx_lyrics_video_id ON lyrics(video_id);
CREATE INDEX IF NOT EXISTS idx_lyrics_song_artist ON lyrics(song_norm, artist_norm);
-- Votes table (for quality control)
CREATE TABLE IF NOT EXISTS votes (
id SERIAL PRIMARY KEY,
lyrics_id INTEGER NOT NULL REFERENCES lyrics(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
vote INTEGER CHECK(vote IN (-1, 1)) NOT NULL,
is_self_vote INTEGER DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
UNIQUE(lyrics_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_votes_lyrics ON votes(lyrics_id);
CREATE INDEX IF NOT EXISTS idx_votes_user ON votes(user_id);
-- Reports table (for flagging bad lyrics)
CREATE TABLE IF NOT EXISTS reports (
id SERIAL PRIMARY KEY,
lyrics_id INTEGER NOT NULL REFERENCES lyrics(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
reason TEXT CHECK(reason IN ('wrong_song', 'bad_sync', 'offensive', 'spam', 'other')) NOT NULL,
details TEXT,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
UNIQUE(lyrics_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_reports_lyrics ON reports(lyrics_id);
CREATE INDEX IF NOT EXISTS idx_lyrics_effective_score ON lyrics(effective_score DESC);
-- Migrations
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS isrc TEXT;
-- Trigram search support
CREATE EXTENSION IF NOT EXISTS pg_trgm;
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS album_norm TEXT;
UPDATE lyrics SET album_norm = LOWER(TRIM(album)) WHERE album IS NOT NULL AND album_norm IS NULL;
CREATE INDEX IF NOT EXISTS idx_lyrics_song_norm_trgm ON lyrics USING GIN (song_norm gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_lyrics_artist_norm_trgm ON lyrics USING GIN (artist_norm gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_lyrics_album_norm_trgm ON lyrics USING GIN (album_norm gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_lyrics_song_artist_norm_trgm ON lyrics USING GIN ((song_norm || ' ' || artist_norm) gin_trgm_ops);
-- Vote updates are rarely HOT, so a pending list would grow between vacuums and every search scans it.
ALTER INDEX idx_lyrics_song_norm_trgm SET (fastupdate = off);
ALTER INDEX idx_lyrics_artist_norm_trgm SET (fastupdate = off);
ALTER INDEX idx_lyrics_album_norm_trgm SET (fastupdate = off);
ALTER INDEX idx_lyrics_song_artist_norm_trgm SET (fastupdate = off);
-- Full-text search on lyrics content
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS lyrics_text_search tsvector;
CREATE INDEX IF NOT EXISTS idx_lyrics_text_search ON lyrics USING GIN (lyrics_text_search);
-- Multi-variant lyrics: allow multiple entries per video_id
-- Drop the unique constraint so multiple submissions can coexist
ALTER TABLE lyrics DROP CONSTRAINT IF EXISTS lyrics_video_id_key;
-- Drop the unique submitter constraint (users can submit multiple variants, capped in app logic)
DROP INDEX IF EXISTS idx_lyrics_video_submitter;
-- Composite index for efficient "best variant" lookups
CREATE INDEX IF NOT EXISTS idx_lyrics_video_id_ranking
ON lyrics(video_id, effective_score DESC);
-- Per-variant multi-video linking: a lyric serves its home lyrics.video_id plus every id here.
CREATE TABLE IF NOT EXISTS lyrics_video_ids (
lyrics_id INTEGER NOT NULL REFERENCES lyrics(id) ON DELETE CASCADE,
video_id TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INT),
PRIMARY KEY (lyrics_id, video_id)
);
CREATE INDEX IF NOT EXISTS idx_lvi_video_id ON lyrics_video_ids(video_id);
-- Soft delete for submissions: preserves vote/reputation signal
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS deleted_at INTEGER;
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS deleted_by_user_id INTEGER REFERENCES users(id);
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS deleted_by_role TEXT
CHECK (deleted_by_role IN ('submitter', 'admin'));
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS deletion_reason TEXT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'lyrics_deletion_consistency'
) THEN
ALTER TABLE lyrics ADD CONSTRAINT lyrics_deletion_consistency CHECK (
(deleted_at IS NULL AND deleted_by_user_id IS NULL AND deleted_by_role IS NULL)
OR (deleted_at IS NOT NULL AND deleted_by_user_id IS NOT NULL AND deleted_by_role IS NOT NULL)
);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_lyrics_active ON lyrics(id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_lyrics_submitter_created
ON lyrics(submitter_id, created_at DESC)
WHERE deleted_at IS NULL;
-- Reputation penalty idempotency: tracks whether the auto-hide / dirty-delete
-- penalty has already been applied for this row.
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS reputation_penalized BOOLEAN DEFAULT FALSE;
-- Language detection metadata.
-- `language` keeps its semantic meaning (NULL = unknown).
-- `language_source` records whether the language came from the submitter or
-- from the detection service ('submitter' or 'detector'). NULL means we never
-- looked at it (legacy rows).
-- `language_detector_version` records the version of the detection pipeline.
-- NULL means the detector was unreachable when the row was last touched, so
-- the backfill should retry. Non-NULL with the current version means the row
-- is up to date.
-- `language_detection_attempted_at` records the last attempt timestamp for
-- observability.
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS language_source TEXT
CHECK (language_source IS NULL OR language_source IN ('submitter', 'detector'));
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS language_detector_version SMALLINT;
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS language_detection_attempted_at TIMESTAMPTZ;
-- Index for backfill sweep: pick up rows where detector wasn't authoritative.
CREATE INDEX IF NOT EXISTS idx_lyrics_language_backfill
ON lyrics(id)
WHERE COALESCE(language_source, 'detector') <> 'submitter'
AND deleted_at IS NULL;
-- Lyrics requests: demand signal for songs missing synced lyrics
CREATE TABLE IF NOT EXISTS requested_songs (
video_id TEXT PRIMARY KEY,
song TEXT NOT NULL,
artist TEXT NOT NULL,
thumbnail_url TEXT,
first_requested_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
last_requested_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER)
);
CREATE TABLE IF NOT EXISTS lyrics_requests (
id BIGSERIAL PRIMARY KEY,
video_id TEXT NOT NULL REFERENCES requested_songs(video_id) ON DELETE CASCADE,
requester_id TEXT NOT NULL,
requester_type TEXT CHECK(requester_type IN ('extension', 'discord')) NOT NULL DEFAULT 'extension',
weight DOUBLE PRECISION NOT NULL DEFAULT 1.0,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
UNIQUE(video_id, requester_id, requester_type)
);
CREATE INDEX IF NOT EXISTS idx_lyrics_requests_created ON lyrics_requests(created_at);
-- Request fulfillments: recognition when a synced submission fills demand
CREATE TABLE IF NOT EXISTS request_fulfillments (
id BIGSERIAL PRIMARY KEY,
video_id TEXT NOT NULL,
lyrics_id INTEGER NOT NULL REFERENCES lyrics(id) ON DELETE CASCADE,
submitter_id INTEGER REFERENCES users(id),
demand_snapshot DOUBLE PRECISION NOT NULL,
request_count_snapshot INTEGER NOT NULL,
fulfilled_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER)
);
CREATE INDEX IF NOT EXISTS idx_request_fulfillments_submitter
ON request_fulfillments(submitter_id);
CREATE INDEX IF NOT EXISTS idx_request_fulfillments_video_fulfilled
ON request_fulfillments(video_id, fulfilled_at DESC);
CREATE INDEX IF NOT EXISTS idx_request_fulfillments_lyrics
ON request_fulfillments(lyrics_id);
-- Translation cache: durable per-song translation + romanization from the Google
-- lyrics_translate proxy, keyed so Google is hit at most once per unique song+language
CREATE TABLE IF NOT EXISTS translation_cache (
id BIGSERIAL PRIMARY KEY,
lyrics_hash TEXT NOT NULL,
from_lang TEXT NOT NULL,
to_lang TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'google-lyrics-translate',
video_id TEXT,
line_count INT NOT NULL,
detected_source_lang TEXT,
has_romanization BOOLEAN NOT NULL DEFAULT FALSE,
is_negative BOOLEAN NOT NULL DEFAULT FALSE,
source_lines JSONB NOT NULL,
lines JSONB NOT NULL,
google_version TEXT,
google_id TEXT,
google_token TEXT,
http_status INT,
raw_payload TEXT,
parser_version INT NOT NULL,
failure_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
UNIQUE (lyrics_hash, from_lang, to_lang, provider)
);
ALTER TABLE translation_cache ADD COLUMN IF NOT EXISTS failure_count INT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS translation_cache_expires_idx ON translation_cache (expires_at);
CREATE INDEX IF NOT EXISTS translation_cache_video_idx ON translation_cache (video_id);
CREATE INDEX IF NOT EXISTS translation_cache_to_lang_idx ON translation_cache (to_lang);
CREATE TABLE IF NOT EXISTS migration_requests (
id BIGSERIAL PRIMARY KEY,
session_id TEXT,
discord_id TEXT NOT NULL,
old_key TEXT NOT NULL,
new_key TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('preview', 'committed', 'failed')),
moved_submissions INTEGER NOT NULL DEFAULT 0,
moved_votes INTEGER NOT NULL DEFAULT 0,
moved_reports INTEGER NOT NULL DEFAULT 0,
moved_fulfillments INTEGER NOT NULL DEFAULT 0,
collisions_dropped INTEGER NOT NULL DEFAULT 0,
snapshot JSONB,
error TEXT,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
updated_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER)
);
CREATE INDEX IF NOT EXISTS idx_migration_requests_discord ON migration_requests(discord_id);
-- ---- gamified reputation ----
CREATE TABLE IF NOT EXISTS contribution_events (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
delta INTEGER NOT NULL,
kind TEXT NOT NULL,
ref_type TEXT NOT NULL DEFAULT '',
ref_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
UNIQUE(user_id, kind, ref_type, ref_id)
);
CREATE TABLE IF NOT EXISTS badge_awards (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
badge_key TEXT NOT NULL,
tier INTEGER,
awarded_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
context TEXT,
UNIQUE(user_id, badge_key)
);
CREATE TABLE IF NOT EXISTS committee_members (
user_id INTEGER PRIMARY KEY REFERENCES users(id),
added_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
added_by TEXT
);
CREATE TABLE IF NOT EXISTS boosts (
id SERIAL PRIMARY KEY,
booster_id INTEGER NOT NULL REFERENCES users(id),
lyrics_id INTEGER NOT NULL REFERENCES lyrics(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
revoked_at INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_boosts_active_lyric
ON boosts(lyrics_id) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_boosts_booster ON boosts(booster_id);
CREATE TABLE IF NOT EXISTS rejections (
id SERIAL PRIMARY KEY,
lyrics_id INTEGER NOT NULL REFERENCES lyrics(id) ON DELETE CASCADE,
rejected_by INTEGER NOT NULL REFERENCES users(id),
rejected_at INTEGER NOT NULL,
note TEXT,
revoked_at INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_rejections_active
ON rejections(lyrics_id) WHERE revoked_at IS NULL;
-- NULL on a revoked row is a legacy undo; 'edited' lapses when the lyrics change, not a council action.
ALTER TABLE rejections ADD COLUMN IF NOT EXISTS revoke_reason TEXT
CHECK (revoke_reason IN ('undo', 'edited'));
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS committee_approved_at INTEGER;
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS committee_approved_by INTEGER REFERENCES users(id);
CREATE INDEX IF NOT EXISTS idx_lyrics_sealed
ON lyrics(committee_approved_at DESC)
WHERE committee_approved_at IS NOT NULL AND deleted_at IS NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS featured_badges TEXT;
-- Shadowban: every lyric by a banned user is hidden, nothing is deleted
ALTER TABLE users ADD COLUMN IF NOT EXISTS banned_at INTEGER;
CREATE INDEX IF NOT EXISTS idx_users_banned ON users(id) WHERE banned_at IS NOT NULL;
-- ---- council entry exam ----
-- Table structure only; question content and answer keys live only in seeded rows.
CREATE TABLE IF NOT EXISTS exam_question (
id SERIAL PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('timing', 'mcq', 'scenario')),
category TEXT NOT NULL,
prompt TEXT NOT NULL,
assets JSONB,
choices JSONB,
answer_key JSONB NOT NULL,
weight DOUBLE PRECISION NOT NULL DEFAULT 1.0,
steps JSONB,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
updated_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER)
);
CREATE INDEX IF NOT EXISTS idx_exam_question_pool
ON exam_question(category) WHERE active = TRUE;
CREATE TABLE IF NOT EXISTS exam_session (
id BIGSERIAL PRIMARY KEY,
key_id TEXT NOT NULL,
discord_id TEXT,
state TEXT NOT NULL DEFAULT 'in_progress'
CHECK (state IN ('in_progress', 'pending_review', 'failed', 'approved', 'rejected')),
token_hash TEXT,
score DOUBLE PRECISION,
max_score DOUBLE PRECISION,
cutoff DOUBLE PRECISION,
seed BIGINT NOT NULL,
is_dev BOOLEAN NOT NULL DEFAULT FALSE,
started_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
exam_started_at INTEGER,
expires_at INTEGER NOT NULL,
submitted_at INTEGER,
decided_at INTEGER,
decided_by_discord_id TEXT
);
-- Stamped on first Begin (not at mint) so the clock anchors there and resumes on reload.
ALTER TABLE exam_session ADD COLUMN IF NOT EXISTS exam_started_at INTEGER;
-- DB backstop for one open real attempt per account; retakes after a cooldown add rows, dev sessions are exempt.
DROP INDEX IF EXISTS idx_exam_session_one_per_account;
CREATE UNIQUE INDEX IF NOT EXISTS idx_exam_session_one_open_per_account
ON exam_session(key_id) WHERE is_dev = FALSE AND state = 'in_progress';
CREATE INDEX IF NOT EXISTS idx_exam_session_key ON exam_session(key_id, id DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_exam_session_token
ON exam_session(token_hash) WHERE token_hash IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_exam_session_state ON exam_session(state);
CREATE INDEX IF NOT EXISTS idx_exam_session_discord ON exam_session(discord_id);
CREATE TABLE IF NOT EXISTS exam_session_question (
id BIGSERIAL PRIMARY KEY,
session_id BIGINT NOT NULL REFERENCES exam_session(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES exam_question(id),
position INTEGER NOT NULL,
answer JSONB,
awarded_points DOUBLE PRECISION,
max_points DOUBLE PRECISION,
UNIQUE(session_id, question_id),
UNIQUE(session_id, position)
);
CREATE INDEX IF NOT EXISTS idx_exam_session_question_session
ON exam_session_question(session_id);
CREATE TABLE IF NOT EXISTS song_artwork (
video_id TEXT PRIMARY KEY,
artwork_url TEXT,
checked_at BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS avatar_presets (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
file TEXT NOT NULL UNIQUE,
created_by TEXT,
created_at BIGINT NOT NULL DEFAULT 0
);
ALTER TABLE avatar_presets ADD COLUMN IF NOT EXISTS published_at BIGINT;
-- ---- lyric revisions ----
-- Edit history. The lyrics content columns cache the live revision.
CREATE TABLE IF NOT EXISTS lyric_revisions (
id SERIAL PRIMARY KEY,
lyrics_id INTEGER NOT NULL REFERENCES lyrics(id) ON DELETE CASCADE,
rev_no INTEGER NOT NULL,
lyrics TEXT NOT NULL,
format TEXT NOT NULL CHECK (format IN ('ttml', 'lrc', 'plain')),
sync_type TEXT NOT NULL CHECK (sync_type IN ('richsync', 'linesync', 'plain')),
language TEXT,
isrc TEXT,
content_hash TEXT NOT NULL,
author_id INTEGER REFERENCES users(id),
status TEXT NOT NULL
CHECK (status IN ('live', 'past', 'pending', 'superseded', 'rejected', 'withdrawn')),
pending_reason TEXT
CHECK (pending_reason IN ('sealed', 'flagged', 'large_text_drift', 'large_timing_drift')),
text_drift DOUBLE PRECISION NOT NULL DEFAULT 0,
timing_drift DOUBLE PRECISION NOT NULL DEFAULT 0,
jev_probability DOUBLE PRECISION,
reverts_revision_id INTEGER REFERENCES lyric_revisions(id),
reviewed_by INTEGER REFERENCES users(id),
reviewed_at INTEGER,
review_note TEXT,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
UNIQUE (lyrics_id, rev_no)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_lyric_revisions_one_live
ON lyric_revisions(lyrics_id) WHERE status = 'live';
CREATE UNIQUE INDEX IF NOT EXISTS idx_lyric_revisions_one_pending
ON lyric_revisions(lyrics_id) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_lyric_revisions_author_created
ON lyric_revisions(author_id, created_at);
CREATE INDEX IF NOT EXISTS idx_lyric_revisions_pending_created
ON lyric_revisions(created_at) WHERE status = 'pending';
-- Backfill only on first add, so a rerun never restores an album a revision cleared.
-- album_known stays FALSE on rows written by code that predates the album column.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'lyric_revisions' AND column_name = 'album'
) THEN
ALTER TABLE lyric_revisions ADD COLUMN album TEXT;
UPDATE lyric_revisions r SET album = l.album FROM lyrics l WHERE l.id = r.lyrics_id;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'lyric_revisions' AND column_name = 'album_known'
) THEN
ALTER TABLE lyric_revisions ADD COLUMN album_known BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE lyric_revisions ALTER COLUMN album_known SET DEFAULT FALSE;
END IF;
END $$;
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS current_revision_id INTEGER
REFERENCES lyric_revisions(id) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS anchor_revision_id INTEGER
REFERENCES lyric_revisions(id) DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX IF NOT EXISTS idx_lyrics_without_revision
ON lyrics(id) WHERE current_revision_id IS NULL;
-- Edit-as-variant lineage, replaced by lyric_revisions. Prod had no rows using it.
ALTER TABLE lyrics DROP COLUMN IF EXISTS parent_id;
-- ---- council dashboard ----
ALTER TABLE committee_members ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS council_bookmarks (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
item_type TEXT NOT NULL CHECK (item_type IN ('seal', 'edit')),
item_id INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
released_at INTEGER,
release_reason TEXT CHECK (release_reason IN ('released', 'decided'))
);
CREATE INDEX IF NOT EXISTS idx_council_bookmarks_open
ON council_bookmarks(item_type, item_id) WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_council_bookmarks_user_open
ON council_bookmarks(user_id) WHERE released_at IS NULL;
CREATE TABLE IF NOT EXISTS council_events (
id SERIAL PRIMARY KEY,
actor_id INTEGER REFERENCES users(id),
kind TEXT NOT NULL CHECK (kind IN (
'seal', 'unseal', 'reject', 'unreject', 'edit_approve', 'edit_reject',
'bookmark', 'release', 'member_add', 'member_remove',
'applicant_approve', 'applicant_reject',
'metadata_propose', 'metadata_approve', 'metadata_reject')),
source TEXT NOT NULL CHECK (source IN ('web', 'discord', 'admin')),
lyrics_id INTEGER REFERENCES lyrics(id) ON DELETE CASCADE,
ref_id BIGINT,
subject_user_id INTEGER REFERENCES users(id),
note TEXT,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER)
);
CREATE INDEX IF NOT EXISTS idx_council_events_created ON council_events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_council_events_actor ON council_events(actor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_council_events_lyric ON council_events(lyrics_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_council_events_decision_ref ON council_events(kind, ref_id)
WHERE kind IN ('seal', 'unseal', 'reject', 'unreject', 'edit_approve', 'edit_reject');
CREATE TABLE IF NOT EXISTS applicant_opinions (
exam_session_id BIGINT NOT NULL REFERENCES exam_session(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
stance TEXT NOT NULL CHECK (stance IN ('support', 'object')),
note TEXT,
updated_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
PRIMARY KEY (exam_session_id, user_id)
);
ALTER TABLE exam_session ADD COLUMN IF NOT EXISTS decided_by_user_id INTEGER REFERENCES users(id);
-- ---- council metadata edits ----
-- The inline CHECK above only reaches new databases, so existing ones get it replaced once.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'council_events_kind_check'
AND pg_get_constraintdef(oid) LIKE '%metadata_propose%'
) THEN
ALTER TABLE council_events DROP CONSTRAINT IF EXISTS council_events_kind_check;
ALTER TABLE council_events ADD CONSTRAINT council_events_kind_check CHECK (kind IN (
'seal', 'unseal', 'reject', 'unreject', 'edit_approve', 'edit_reject',
'bookmark', 'release', 'member_add', 'member_remove',
'applicant_approve', 'applicant_reject',
'metadata_propose', 'metadata_approve', 'metadata_reject'));
END IF;
END $$;
CREATE TABLE IF NOT EXISTS metadata_proposals (
id SERIAL PRIMARY KEY,
video_id TEXT NOT NULL,
lyrics_id INTEGER NOT NULL REFERENCES lyrics(id) ON DELETE CASCADE,
proposer_id INTEGER NOT NULL REFERENCES users(id),
song TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
before_song TEXT NOT NULL,
before_artist TEXT NOT NULL,
before_album TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'passed', 'rejected')),
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
decided_at INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_metadata_proposals_one_open
ON metadata_proposals(video_id) WHERE status = 'open';
CREATE INDEX IF NOT EXISTS idx_metadata_proposals_passed
ON metadata_proposals(video_id, decided_at DESC) WHERE status = 'passed';
CREATE TABLE IF NOT EXISTS metadata_votes (
proposal_id INTEGER NOT NULL REFERENCES metadata_proposals(id) ON DELETE CASCADE,
voter_id INTEGER NOT NULL REFERENCES users(id),
approve BOOLEAN NOT NULL,
created_at INTEGER NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::INTEGER),
PRIMARY KEY (proposal_id, voter_id)
);