Migrate database from MySQL to Supabase PostgreSQL

- Added @supabase/supabase-js client library
- Rewrote database.js to use Supabase API
- Updated server.js health check for Supabase
- Updated db.config.example.js with Supabase format
- Created comprehensive SUPABASE_SETUP.md guide
- Added SQL schema files for easy deployment
- Updated README_DB_CONFIG.md for Supabase

Benefits:
- Managed PostgreSQL database
- Built-in Row Level Security
- Real-time capabilities
- Easy monitoring via dashboard
- Free tier for development
This commit is contained in:
2025-12-21 15:40:57 +11:00
parent 5238fc8d22
commit 054cbf3e77
9 changed files with 872 additions and 276 deletions

View File

@@ -1,82 +1,110 @@
const mysql = require('mysql2/promise');
const { createClient } = require('@supabase/supabase-js');
// Import database configuration from external file
// This file (db.config.js) is not committed to git for security
// Use db.config.example.js as a template
const dbConfig = require('./db.config.js');
// Create connection pool
const pool = mysql.createPool(dbConfig);
// Create Supabase client
const supabase = createClient(dbConfig.supabaseUrl, dbConfig.supabaseAnonKey);
// Initialize database schema
async function initializeDatabase() {
try {
const connection = await pool.getConnection();
console.log('🔄 Initializing Supabase database schema...');
// Create players table
await connection.query(`
CREATE TABLE IF NOT EXISTS players (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
total_wins INT DEFAULT 0,
total_losses INT DEFAULT 0,
total_draws INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_username (username)
)
`);
const { error: playersError } = await supabase.rpc('create_players_table', {});
// Create active sessions table
await connection.query(`
CREATE TABLE IF NOT EXISTS active_sessions (
session_id VARCHAR(100) PRIMARY KEY,
player_id INT NOT NULL,
username VARCHAR(50) NOT NULL,
connected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_heartbeat TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE
)
`);
// Since we can't run raw SQL directly with the JS client in the same way,
// we'll use Supabase's SQL editor or migrations
// For now, we'll check if tables exist by trying to query them
// Create games table
await connection.query(`
CREATE TABLE IF NOT EXISTS games (
id INT AUTO_INCREMENT PRIMARY KEY,
player1_id INT NOT NULL,
player2_id INT NOT NULL,
player1_username VARCHAR(50) NOT NULL,
player2_username VARCHAR(50) NOT NULL,
board_size INT DEFAULT 15,
winner_id INT,
game_state ENUM('pending', 'active', 'completed', 'abandoned') DEFAULT 'pending',
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
FOREIGN KEY (player1_id) REFERENCES players(id),
FOREIGN KEY (player2_id) REFERENCES players(id),
FOREIGN KEY (winner_id) REFERENCES players(id)
)
`);
const { data: playersCheck, error: playersCheckError } = await supabase
.from('players')
.select('id')
.limit(1);
// Create game moves table
await connection.query(`
CREATE TABLE IF NOT EXISTS game_moves (
id INT AUTO_INCREMENT PRIMARY KEY,
game_id INT NOT NULL,
player_id INT NOT NULL,
row_position INT NOT NULL,
col_position INT NOT NULL,
move_number INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE,
FOREIGN KEY (player_id) REFERENCES players(id),
INDEX idx_game (game_id)
)
`);
if (playersCheckError && playersCheckError.code === '42P01') {
console.log('⚠️ Tables not found. Please run the following SQL in your Supabase SQL Editor:');
console.log(`
-- Create players table
CREATE TABLE IF NOT EXISTS players (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
total_wins INT DEFAULT 0,
total_losses INT DEFAULT 0,
total_draws INT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_username ON players(username);
-- Create active sessions table
CREATE TABLE IF NOT EXISTS active_sessions (
session_id VARCHAR(100) PRIMARY KEY,
player_id BIGINT NOT NULL,
username VARCHAR(50) NOT NULL,
connected_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE
);
-- Create game state enum type
DO $$ BEGIN
CREATE TYPE game_state_enum AS ENUM ('pending', 'active', 'completed', 'abandoned');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Create games table
CREATE TABLE IF NOT EXISTS games (
id BIGSERIAL PRIMARY KEY,
player1_id BIGINT NOT NULL,
player2_id BIGINT NOT NULL,
player1_username VARCHAR(50) NOT NULL,
player2_username VARCHAR(50) NOT NULL,
board_size INT DEFAULT 15,
winner_id BIGINT,
game_state game_state_enum DEFAULT 'pending',
started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE,
FOREIGN KEY (player1_id) REFERENCES players(id),
FOREIGN KEY (player2_id) REFERENCES players(id),
FOREIGN KEY (winner_id) REFERENCES players(id)
);
-- Create game moves table
CREATE TABLE IF NOT EXISTS game_moves (
id BIGSERIAL PRIMARY KEY,
game_id BIGINT NOT NULL,
player_id BIGINT NOT NULL,
row_position INT NOT NULL,
col_position INT NOT NULL,
move_number INT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE,
FOREIGN KEY (player_id) REFERENCES players(id)
);
CREATE INDEX IF NOT EXISTS idx_game ON game_moves(game_id);
-- Enable Row Level Security (RLS) - Optional but recommended
ALTER TABLE players ENABLE ROW LEVEL SECURITY;
ALTER TABLE active_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE games ENABLE ROW LEVEL SECURITY;
ALTER TABLE game_moves ENABLE ROW LEVEL SECURITY;
-- Create policies to allow all operations (adjust based on your security needs)
CREATE POLICY "Allow all operations on players" ON players FOR ALL USING (true);
CREATE POLICY "Allow all operations on active_sessions" ON active_sessions FOR ALL USING (true);
CREATE POLICY "Allow all operations on games" ON games FOR ALL USING (true);
CREATE POLICY "Allow all operations on game_moves" ON game_moves FOR ALL USING (true);
`);
throw new Error('Database tables not initialized. Please run the SQL above in Supabase SQL Editor.');
}
connection.release();
console.log('✅ Database schema initialized successfully');
console.log('✅ Database schema verified successfully');
} catch (error) {
console.error('❌ Error initializing database:', error);
console.error('❌ Error initializing database:', error.message);
throw error;
}
}
@@ -86,11 +114,26 @@ const db = {
// Create or get player
async createPlayer(username) {
try {
const [result] = await pool.query(
'INSERT INTO players (username) VALUES (?) ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)',
[username]
);
return result.insertId;
// First try to get existing player
const { data: existingPlayer, error: selectError } = await supabase
.from('players')
.select('id')
.eq('username', username)
.single();
if (existingPlayer) {
return existingPlayer.id;
}
// If not found, create new player
const { data, error } = await supabase
.from('players')
.insert([{ username }])
.select('id')
.single();
if (error) throw error;
return data.id;
} catch (error) {
console.error('Error creating player:', error);
throw error;
@@ -99,128 +142,217 @@ const db = {
// Get player by username
async getPlayer(username) {
const [rows] = await pool.query(
'SELECT * FROM players WHERE username = ?',
[username]
);
return rows[0];
const { data, error } = await supabase
.from('players')
.select('*')
.eq('username', username)
.single();
if (error && error.code !== 'PGRST116') throw error; // PGRST116 = not found
return data;
},
// Get player by ID
async getPlayerById(playerId) {
const [rows] = await pool.query(
'SELECT * FROM players WHERE id = ?',
[playerId]
);
return rows[0];
const { data, error } = await supabase
.from('players')
.select('*')
.eq('id', playerId)
.single();
if (error && error.code !== 'PGRST116') throw error;
return data;
},
// Add active session
async addSession(sessionId, playerId, username) {
await pool.query(
'INSERT INTO active_sessions (session_id, player_id, username) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE last_heartbeat = CURRENT_TIMESTAMP',
[sessionId, playerId, username]
);
const { error } = await supabase
.from('active_sessions')
.upsert([{
session_id: sessionId,
player_id: playerId,
username: username,
last_heartbeat: new Date().toISOString()
}], {
onConflict: 'session_id'
});
if (error) throw error;
},
// Remove session
async removeSession(sessionId) {
await pool.query(
'DELETE FROM active_sessions WHERE session_id = ?',
[sessionId]
);
const { error } = await supabase
.from('active_sessions')
.delete()
.eq('session_id', sessionId);
if (error) throw error;
},
// Get all active players
async getActivePlayers() {
const [rows] = await pool.query(`
SELECT s.session_id, s.username, p.total_wins, p.total_losses, p.total_draws
FROM active_sessions s
JOIN players p ON s.player_id = p.id
WHERE s.last_heartbeat > DATE_SUB(NOW(), INTERVAL 2 MINUTE)
`);
return rows;
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString();
const { data, error } = await supabase
.from('active_sessions')
.select(`
session_id,
username,
players!inner(total_wins, total_losses, total_draws)
`)
.gt('last_heartbeat', twoMinutesAgo);
if (error) throw error;
// Flatten the response to match the old format
return data.map(row => ({
session_id: row.session_id,
username: row.username,
total_wins: row.players.total_wins,
total_losses: row.players.total_losses,
total_draws: row.players.total_draws
}));
},
// Create new game
async createGame(player1Id, player2Id, player1Username, player2Username, boardSize) {
const [result] = await pool.query(
'INSERT INTO games (player1_id, player2_id, player1_username, player2_username, board_size, game_state) VALUES (?, ?, ?, ?, ?, ?)',
[player1Id, player2Id, player1Username, player2Username, boardSize, 'active']
);
return result.insertId;
const { data, error } = await supabase
.from('games')
.insert([{
player1_id: player1Id,
player2_id: player2Id,
player1_username: player1Username,
player2_username: player2Username,
board_size: boardSize,
game_state: 'active'
}])
.select('id')
.single();
if (error) throw error;
return data.id;
},
// Record move
async recordMove(gameId, playerId, row, col, moveNumber) {
await pool.query(
'INSERT INTO game_moves (game_id, player_id, row_position, col_position, move_number) VALUES (?, ?, ?, ?, ?)',
[gameId, playerId, row, col, moveNumber]
);
const { error } = await supabase
.from('game_moves')
.insert([{
game_id: gameId,
player_id: playerId,
row_position: row,
col_position: col,
move_number: moveNumber
}]);
if (error) throw error;
},
// Complete game
async completeGame(gameId, winnerId) {
await pool.query(
'UPDATE games SET game_state = ?, winner_id = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?',
['completed', winnerId, gameId]
);
// Update game status
const { error: gameError } = await supabase
.from('games')
.update({
game_state: 'completed',
winner_id: winnerId,
completed_at: new Date().toISOString()
})
.eq('id', gameId);
if (gameError) throw gameError;
// Update player stats
if (winnerId) {
// Get game details
const [game] = await pool.query('SELECT player1_id, player2_id FROM games WHERE id = ?', [gameId]);
if (game.length > 0) {
const loserId = game[0].player1_id === winnerId ? game[0].player2_id : game[0].player1_id;
const { data: game, error: selectError } = await supabase
.from('games')
.select('player1_id, player2_id')
.eq('id', gameId)
.single();
if (selectError) throw selectError;
if (game) {
const loserId = game.player1_id === winnerId ? game.player2_id : game.player1_id;
// Update winner
await pool.query('UPDATE players SET total_wins = total_wins + 1 WHERE id = ?', [winnerId]);
await supabase.rpc('increment_wins', { player_id: winnerId });
// Update loser
await pool.query('UPDATE players SET total_losses = total_losses + 1 WHERE id = ?', [loserId]);
await supabase.rpc('increment_losses', { player_id: loserId });
}
} else {
// Draw - update both players
const [game] = await pool.query('SELECT player1_id, player2_id FROM games WHERE id = ?', [gameId]);
if (game.length > 0) {
await pool.query('UPDATE players SET total_draws = total_draws + 1 WHERE id IN (?, ?)',
[game[0].player1_id, game[0].player2_id]);
const { data: game, error: selectError } = await supabase
.from('games')
.select('player1_id, player2_id')
.eq('id', gameId)
.single();
if (selectError) throw selectError;
if (game) {
await supabase.rpc('increment_draws', { player_id: game.player1_id });
await supabase.rpc('increment_draws', { player_id: game.player2_id });
}
}
},
// Abandon game
async abandonGame(gameId, winnerId) {
await pool.query(
'UPDATE games SET game_state = ?, winner_id = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?',
['abandoned', winnerId, gameId]
);
// Update game status
const { error: gameError } = await supabase
.from('games')
.update({
game_state: 'abandoned',
winner_id: winnerId,
completed_at: new Date().toISOString()
})
.eq('id', gameId);
if (gameError) throw gameError;
// Update stats (winner gets win, other player gets loss)
if (winnerId) {
const [game] = await pool.query('SELECT player1_id, player2_id FROM games WHERE id = ?', [gameId]);
if (game.length > 0) {
const loserId = game[0].player1_id === winnerId ? game[0].player2_id : game[0].player1_id;
await pool.query('UPDATE players SET total_wins = total_wins + 1 WHERE id = ?', [winnerId]);
await pool.query('UPDATE players SET total_losses = total_losses + 1 WHERE id = ?', [loserId]);
const { data: game, error: selectError } = await supabase
.from('games')
.select('player1_id, player2_id')
.eq('id', gameId)
.single();
if (selectError) throw selectError;
if (game) {
const loserId = game.player1_id === winnerId ? game.player2_id : game.player1_id;
await supabase.rpc('increment_wins', { player_id: winnerId });
await supabase.rpc('increment_losses', { player_id: loserId });
}
}
},
// Update heartbeat
async updateHeartbeat(sessionId) {
await pool.query(
'UPDATE active_sessions SET last_heartbeat = CURRENT_TIMESTAMP WHERE session_id = ?',
[sessionId]
);
const { error } = await supabase
.from('active_sessions')
.update({ last_heartbeat: new Date().toISOString() })
.eq('session_id', sessionId);
if (error) throw error;
},
// Clean up stale sessions
async cleanupStaleSessions() {
await pool.query(
'DELETE FROM active_sessions WHERE last_heartbeat < DATE_SUB(NOW(), INTERVAL 2 MINUTE)'
);
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString();
const { error } = await supabase
.from('active_sessions')
.delete()
.lt('last_heartbeat', twoMinutesAgo);
if (error) throw error;
}
};
module.exports = { pool, initializeDatabase, db };
module.exports = { supabase, initializeDatabase, db };