From 624879f2faa0b25ad474b9e17b6a75c35e5a3aa5 Mon Sep 17 00:00:00 2001 From: Evan Date: Sat, 15 Feb 2025 09:12:26 -0800 Subject: [PATCH] add database layer --- package-lock.json | 109 ++++++++++++++++++++++++++++++++++++ package.json | 1 + src/server/db/DB.ts | 89 +++++++++++++++++++++++++++++ src/server/db/Index.ts | 120 ++++++++++++++++++++++++++++++++++++++++ src/server/db/Schema.ts | 22 ++++++++ src/server/db/Types.ts | 7 +++ 6 files changed, 348 insertions(+) create mode 100644 src/server/db/DB.ts create mode 100644 src/server/db/Index.ts create mode 100644 src/server/db/Schema.ts create mode 100644 src/server/db/Types.ts diff --git a/package-lock.json b/package-lock.json index 3487256bf..521cbbfb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "@types/jquery": "^3.5.31", "@types/mocha": "^10.0.7", "@types/node": "^22.10.2", + "@types/pg": "^8.11.11", "@types/sinon": "^17.0.3", "@types/uuid": "^10.0.0", "@types/ws": "^8.5.11", @@ -4701,6 +4702,18 @@ "@types/node": "*" } }, + "node_modules/@types/pg": { + "version": "8.11.11", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.11.tgz", + "integrity": "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, "node_modules/@types/qs": { "version": "6.9.16", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", @@ -12997,6 +13010,52 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-protocol": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.1.tgz", + "integrity": "sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/phin": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/phin/-/phin-3.7.1.tgz", @@ -13382,6 +13441,56 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/postgres-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-range": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", + "dev": true, + "license": "MIT" + }, "node_modules/prettier": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.0.tgz", diff --git a/package.json b/package.json index d47e714dc..e20bfd831 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@types/jquery": "^3.5.31", "@types/mocha": "^10.0.7", "@types/node": "^22.10.2", + "@types/pg": "^8.11.11", "@types/sinon": "^17.0.3", "@types/uuid": "^10.0.0", "@types/ws": "^8.5.11", diff --git a/src/server/db/DB.ts b/src/server/db/DB.ts new file mode 100644 index 000000000..576ab7848 --- /dev/null +++ b/src/server/db/DB.ts @@ -0,0 +1,89 @@ +import { Pool, PoolConfig } from "pg"; + +export interface SessionData { + discord_id: string; + session_id: string; + metadata?: Record; + created_at?: Date; + last_active?: Date; +} + +export class Database { + private pool: Pool; + + constructor(pool: Pool) { + this.pool = pool; + } + + /** + * Creates or updates a session + */ + async upsertSession(data: { + discord_id: string; + session_id: string; + metadata?: Record; + }): Promise { + const { discord_id, session_id, metadata = {} } = data; + + try { + const result = await this.pool.query( + `INSERT INTO player_sessions (discord_id, session_id, metadata) + VALUES ($1, $2, $3) + ON CONFLICT (session_id) + DO UPDATE SET + last_active = CURRENT_TIMESTAMP, + metadata = player_sessions.metadata || $3::jsonb + RETURNING *`, + [discord_id, session_id, metadata], + ); + return result.rows[0]; + } catch (error) { + throw new Error(`Failed to create/update session: ${error}`); + } + } + + /** + * Retrieves a session by its ID + */ + async getSession(sessionId: string): Promise { + try { + const result = await this.pool.query( + "SELECT * FROM player_sessions WHERE session_id = $1", + [sessionId], + ); + return result.rows[0] || null; + } catch (error) { + throw new Error(`Failed to fetch session: ${error}`); + } + } + + /** + * Retrieves all sessions for a Discord user + */ + async getUserSessions(discordId: string): Promise { + try { + const result = await this.pool.query( + "SELECT * FROM player_sessions WHERE discord_id = $1 ORDER BY last_active DESC", + [discordId], + ); + return result.rows; + } catch (error) { + throw new Error(`Failed to fetch user sessions: ${error}`); + } + } + + /** + * Deletes a session by its ID + */ + async deleteSession(sessionId: string): Promise { + try { + const result = await this.pool.query( + "DELETE FROM player_sessions WHERE session_id = $1 RETURNING *", + [sessionId], + ); + return result.rows.length > 0; + } catch (error) { + throw new Error(`Failed to delete session: ${error}`); + } + } +} diff --git a/src/server/db/Index.ts b/src/server/db/Index.ts new file mode 100644 index 000000000..6a47964a6 --- /dev/null +++ b/src/server/db/Index.ts @@ -0,0 +1,120 @@ +import { Pool, PoolClient } from "pg"; +import dotenv from "dotenv"; +import { schemas } from "./Schema"; +dotenv.config(); + +// Environment variable interface for type safety +interface DBConfig { + user: string; + host: string; + database: string; + password: string; + port: number; + ssl?: { + rejectUnauthorized: boolean; + }; +} + +// Create the config from environment variables +const createDBConfig = (): DBConfig => { + const config: DBConfig = { + user: process.env.DB_USER || "", + host: process.env.DB_HOST || "", + database: process.env.DB_NAME || "", + password: process.env.DB_PASSWORD || "", + port: parseInt(process.env.DB_PORT || "5432"), + }; + + // Add SSL if enabled + if (process.env.DB_SSL === "true") { + config.ssl = { rejectUnauthorized: false }; + } + + return config; +}; + +const pool = new Pool(createDBConfig()); + +// Error handling for the pool +pool.on("error", (err: Error) => { + console.error("Unexpected error on idle client", err); + process.exit(-1); +}); + +// Initialize database +export const initDB = async (): Promise => { + let client: PoolClient | null = null; + try { + client = await pool.connect(); + console.log("Connected to database, initializing schemas..."); + + // Execute all schema creation queries + await client.query(schemas.playerSessions); + + console.log("Database initialization completed"); + } catch (error) { + console.error("Error initializing database:", error); + throw error; + } finally { + if (client) { + client.release(); + } + } +}; + +// Helper function to get a client from the pool +export const getClient = async (): Promise => { + const client = await pool.connect(); + return client; +}; + +// Query helper with automatic client release +export const query = async (text: string, params?: any[]) => { + const client = await pool.connect(); + try { + return await client.query(text, params); + } finally { + client.release(); + } +}; + +// Transaction helper +export const transaction = async ( + callback: (client: PoolClient) => Promise, +): Promise => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await callback(client); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +// Health check function +export const checkConnection = async (): Promise => { + try { + const client = await pool.connect(); + try { + await client.query("SELECT 1"); + return true; + } finally { + client.release(); + } + } catch (error) { + console.error("Database connection check failed:", error); + return false; + } +}; + +// Clean up function for graceful shutdown +export const closePool = async (): Promise => { + await pool.end(); +}; + +export default pool; diff --git a/src/server/db/Schema.ts b/src/server/db/Schema.ts new file mode 100644 index 000000000..59067737b --- /dev/null +++ b/src/server/db/Schema.ts @@ -0,0 +1,22 @@ +// src/db/schema.ts +export const TABLES = { + PLAYER_SESSIONS: "player_sessions", +} as const; + +export const schemas = { + playerSessions: ` + CREATE TABLE IF NOT EXISTS ${TABLES.PLAYER_SESSIONS} ( + id SERIAL PRIMARY KEY, + discord_id TEXT NOT NULL, + session_id TEXT NOT NULL UNIQUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_active TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + metadata JSONB DEFAULT '{}'::jsonb + ); + + CREATE INDEX IF NOT EXISTS idx_discord_id + ON ${TABLES.PLAYER_SESSIONS}(discord_id); + CREATE INDEX IF NOT EXISTS idx_session_id + ON ${TABLES.PLAYER_SESSIONS}(session_id); + `, +}; diff --git a/src/server/db/Types.ts b/src/server/db/Types.ts new file mode 100644 index 000000000..eca8f4b2b --- /dev/null +++ b/src/server/db/Types.ts @@ -0,0 +1,7 @@ +export interface PlayerSession { + discord_id: string; + session_id: string; + created_at?: Date; + last_active?: Date; + metadata?: Record; +}