Simeon Garratt
feat: remove Supabase, bundle questions locally, use JSON DB + API
ad111f4
Raw
History Blame Contribute Delete
6.38 kB
/**
* JSON file database — single source of truth for server-side state.
*
* All mutations are atomic (write entire file via fs.rename) so concurrent
* requests won't produce half-written files. On HF Spaces the /data directory
* persists across container restarts.
*
* NEVER import this from client code — it uses Node.js fs and has the
* server-side access_token. Client code goes through /api/db instead.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
import { resolve } from "node:path";
import { tmpdir } from "node:os";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PlayerRecord {
id: string;
display_name: string;
anonymous_id: string | null;
elo_rating: number;
games_played: number;
star_coins: number;
claim_code: string | null;
spectrum_position: number | null;
created_at: string;
}
export interface GameSessionRecord {
id: string;
player_id: string;
mode: string;
score: number;
accuracy: number;
streak_max: number;
duration_ms: number;
questions_answered: number;
questions_correct: number;
created_at: string;
}
export interface LeaderboardEntryRecord {
id: string;
player_id: string;
player_name: string;
mode: string;
score: number;
accuracy: number;
season_id: string | null;
created_at: string;
}
export interface PlayerAchievementRecord {
id: string;
player_id: string;
achievement_id: string;
progress: number;
unlocked: boolean;
unlocked_at: string | null;
}
export interface PlayerCosmeticRecord {
id: string;
player_id: string;
cosmetic_id: string;
purchased_at: string;
}
export interface PlayerEquippedRecord {
id: string;
player_id: string;
category: string;
cosmetic_id: string;
}
export interface MatchRecord {
id: string;
player1_id: string;
player2_id: string;
player1_score: number;
player2_score: number;
winner_id: string | null;
elo_change: number;
question_ids: string[];
created_at: string;
}
export interface DebateRecord {
id: string;
topic_id: string;
player1_id: string | null;
player2_id: string | null;
duration_minutes: number;
rounds: number;
winner_id: string | null;
player1_score: number | null;
player2_score: number | null;
verdict: string | null;
elo_change: number | null;
is_practice: boolean | null;
created_at: string;
}
export interface DebateRoundRecord {
id: string;
debate_id: string;
round_number: number;
player1_argument: string | null;
player2_argument: string | null;
created_at: string;
}
export interface DbData {
players: PlayerRecord[];
game_sessions: GameSessionRecord[];
leaderboard_entries: LeaderboardEntryRecord[];
player_achievements: PlayerAchievementRecord[];
player_cosmetics: PlayerCosmeticRecord[];
player_equipped: PlayerEquippedRecord[];
matches: MatchRecord[];
debates: DebateRecord[];
debate_rounds: DebateRoundRecord[];
// Store the seed state for deterministic IDs
_nextId: number;
}
// ---------------------------------------------------------------------------
// File paths
// ---------------------------------------------------------------------------
function getDbDir(): string {
// HF Spaces /data persists; fall back to tmp during dev
return process.env.HF_SPACE_STORAGE_PATH
? resolve(process.env.HF_SPACE_STORAGE_PATH, "triviaverse")
: process.env.DATA_DIR
? process.env.DATA_DIR
: resolve(process.cwd(), ".data");
}
const DB_PATH = () => resolve(getDbDir(), "db.json");
// ---------------------------------------------------------------------------
// Initial data
// ---------------------------------------------------------------------------
const EMPTY_DB: DbData = {
players: [],
game_sessions: [],
leaderboard_entries: [],
player_achievements: [],
player_cosmetics: [],
player_equipped: [],
matches: [],
debates: [],
debate_rounds: [],
_nextId: 1,
};
// ---------------------------------------------------------------------------
// Atomic read / write
// ---------------------------------------------------------------------------
let _cache: DbData | null = null;
export function readDb(): DbData {
if (_cache) return _cache;
const path = DB_PATH();
if (!existsSync(path)) {
// Ensure directory exists
const dir = getDbDir();
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeSync(EMPTY_DB);
_cache = { ...EMPTY_DB };
return _cache;
}
try {
const raw = readFileSync(path, "utf-8");
_cache = JSON.parse(raw) as DbData;
return _cache;
} catch {
_cache = { ...EMPTY_DB };
return _cache;
}
}
function writeSync(data: DbData): void {
const path = DB_PATH();
const tmp = resolve(tmpdir(), `db-${Date.now()}.json`);
writeFileSync(tmp, JSON.stringify(data, null, 1), "utf-8");
renameSync(tmp, path);
}
export function writeDb(data: DbData): void {
_cache = data;
writeSync(data);
}
// ---------------------------------------------------------------------------
// ID generation
// ---------------------------------------------------------------------------
export function nextId(): string {
const db = readDb();
const id = String(db._nextId);
db._nextId++;
writeDb(db);
return id;
}
// ---------------------------------------------------------------------------
// Claim code generation
// ---------------------------------------------------------------------------
const CLAIM_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function generateClaimCode(): string {
return Array.from(
{ length: 6 },
() => CLAIM_CHARSET[Math.floor(Math.random() * CLAIM_CHARSET.length)]
).join("");
}
export function generateUniqueClaimCode(): string {
const db = readDb();
for (let attempt = 0; attempt < 100; attempt++) {
const code = generateClaimCode();
if (!db.players.some((p) => p.claim_code === code)) return code;
}
return `X${Date.now().toString(36).toUpperCase().slice(0, 5)}`;
}
// ---------------------------------------------------------------------------
// Read helpers
// ---------------------------------------------------------------------------
export function getTimestamp(): string {
return new Date().toISOString();
}