add database layer

This commit is contained in:
Evan
2025-02-15 09:12:26 -08:00
parent c7defe4452
commit 624879f2fa
6 changed files with 348 additions and 0 deletions
+109
View File
@@ -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",
+1
View File
@@ -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",
+89
View File
@@ -0,0 +1,89 @@
import { Pool, PoolConfig } from "pg";
export interface SessionData {
discord_id: string;
session_id: string;
metadata?: Record<string, any>;
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<string, any>;
}): Promise<SessionData> {
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<SessionData | null> {
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<SessionData[]> {
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<boolean> {
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}`);
}
}
}
+120
View File
@@ -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<void> => {
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<PoolClient> => {
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 <T>(
callback: (client: PoolClient) => Promise<T>,
): Promise<T> => {
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<boolean> => {
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<void> => {
await pool.end();
};
export default pool;
+22
View File
@@ -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);
`,
};
+7
View File
@@ -0,0 +1,7 @@
export interface PlayerSession {
discord_id: string;
session_id: string;
created_at?: Date;
last_active?: Date;
metadata?: Record<string, any>;
}