diff --git a/.env.example b/.env.example index 83d1a3506..13a670908 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,27 @@ API_IPINFOIO_KEY = # A Google Maps API key. Must have access to the Static Maps API. Remember to restrict your key! # https://developers.google.com/maps/get-started API_GOOGLEMAPS_KEY = + +# The PostgreSQL user to authenticate to +# If you're using Docker with a default configuration, set this to "postgres" +POSTGRES_USER = "postgres" + +# The PostgreSQL password to authenticate with +# If you're using Docker with a default configuration, set this to "postgres" +POSTGRES_PASSWORD = "postgres" + +# The PostgreSQL host to connect to +# If you're using Docker, set this to "postgres" (or the DB container name) +POSTGRES_HOST = "127.0.0.1" + +# The port to connect to PostgreSQL on +# By default, this port is 5432 +POSTGRES_PORT = 5432 + +# The database to use and/or create +# We create a "hibiki" database by default +POSTGRES_DB = "hibiki" + +# The database schema to use and/or create +# We use a "hibiki" schema by default +POSTGRES_SCHEMA = "hibiki" diff --git a/bun.lockb b/bun.lockb index 9f5462e44..9d84f321a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker-compose.yml b/docker-compose.yml index 304a449a8..6c59306ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,24 @@ services: context: . dockerfile: Dockerfile container_name: hibiki + depends_on: + - postgres + restart: unless-stopped + + postgres: + image: postgres:latest + container_name: postgres + hostname: ${POSTGRES_HOST} + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + ports: + - ${POSTGRES_PORT}:${POSTGRES_PORT} + volumes: + - postgres-data:/var/lib/postgresql/data restart: unless-stopped volumes: - hibiki-data: + postgres-data: + hibiki: diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 000000000..0a2c54c99 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,18 @@ +import { env } from "$utils/env.ts"; +import { type Config, defineConfig } from "drizzle-kit"; + +// biome-ignore lint/style/noDefaultExport: Config file +export default defineConfig({ + out: "./drizzle", + schema: "./src/db/schema/*", + dialect: "postgresql", + dbCredentials: { + host: env.POSTGRES_HOST, + port: Number.parseInt(env.POSTGRES_PORT), + user: env.POSTGRES_USER, + password: env.POSTGRES_PASSWORD, + database: env.POSTGRES_DB, + }, + verbose: true, + strict: true, +}) satisfies Config; diff --git a/drizzle/0000_cold_grey_gargoyle.sql b/drizzle/0000_cold_grey_gargoyle.sql new file mode 100644 index 000000000..7d4bfe854 --- /dev/null +++ b/drizzle/0000_cold_grey_gargoyle.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS "guild_config" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "guild_id" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user_config" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL +); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..0e487aa1b --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,62 @@ +{ + "id": "3e0e4620-044b-4dc8-afa3-6628db6c803b", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.guild_config": { + "name": "guild_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_config": { + "name": "user_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 000000000..7e41f7d87 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1724922837098, + "tag": "0000_cold_grey_gargoyle", + "breakpoints": true + } + ] +} diff --git a/package.json b/package.json index 88f4c45f9..3bf8fbe12 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,15 @@ "packageManager": "bun@1.1.26", "scripts": { "check": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", + "db:studio": "bun run db:generate && drizzle-kit studio", "deps:update": "bunx npm-check-updates --target latest --root --upgrade", - "dev": "NODE_ENV=development bun run --hot ./src/index.ts", + "dev": "NODE_ENV=development bun run db:generate && bun run db:push && bun run --hot ./src/index.ts", "lint": "biome check .", "lint:fix": "biome check --apply .", - "start": "NODE_ENV=production bun run ./src/index.ts", + "start": "NODE_ENV=production bun run db:generate && bun run ./src/index.ts", "test": "bun run check && bun run lint", "format": "biome format --write ." }, @@ -24,16 +28,20 @@ "date-fns": "^3.6.0", "discord-api-types": "^0.37.98", "discord.js": "^14.15.3", + "drizzle-orm": "^0.33.0", "i18next": "^23.14.0", "i18next-fs-backend": "^2.3.2", "pino": "^9.3.2", "pino-pretty": "^11.2.2", + "postgres": "^3.4.4", "tslib": "^2.7.0", "zod": "^3.23.8" }, "devDependencies": { "@biomejs/biome": "^1.8.3", "bun-types": "^1.1.26", + "drizzle-kit": "^0.24.2", "typescript": "^5.5.4" - } + }, + "trustedDependencies": ["@biomejs/biome", "esbuild"] } diff --git a/src/db/db.ts b/src/db/db.ts new file mode 100644 index 000000000..bfa451486 --- /dev/null +++ b/src/db/db.ts @@ -0,0 +1,42 @@ +import path from "node:path"; +import { env } from "$utils/env.ts"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; + +// biome-ignore lint/style/noNamespaceImport: Drizzle requies a namespace export +import * as guildConfig from "$db/schema/guild_config.ts"; +// biome-ignore lint/style/noNamespaceImport: Drizzle requies a namespace export +import * as userConfig from "$db/schema/user_config.ts"; + +// __dirname replacement in ESM +const pathDirname = path.dirname(Bun.fileURLToPath(import.meta.url)); +const DRIZZLE_DIRECTORY = path.join(pathDirname, "../../drizzle"); + +const pg = postgres({ + max: 1, + host: env.POSTGRES_HOST, + port: Number.parseInt(env.POSTGRES_PORT), + user: env.POSTGRES_USER, + password: env.POSTGRES_PASSWORD, + database: env.POSTGRES_DB, + + // Sends annoying notices to the shadow realm + onnotice: () => { + return; + }, +}); + +const db = drizzle(pg, { + schema: { + ...userConfig, + ...guildConfig, + }, +}); + +// Runs migrations +await migrate(db, { + migrationsFolder: DRIZZLE_DIRECTORY, +}); + +export { db }; diff --git a/src/db/lib/guild_config.ts b/src/db/lib/guild_config.ts new file mode 100644 index 000000000..3221d8ce8 --- /dev/null +++ b/src/db/lib/guild_config.ts @@ -0,0 +1,56 @@ +import { db } from "$db/db.ts"; +import { guildConfig } from "$db/schema/guild_config.ts"; +import type { HibikiGuildConfig } from "$db/typings/index.d.ts"; +import { eq } from "drizzle-orm"; + +// Gets a guild config +export async function getGuildConfig(guild: string) { + try { + const config = await db.query.guildConfig.findFirst({ + where: (guildConfig, { eq }) => eq(guildConfig.guild_id, guild), + }); + + if (!config?.guild_id) { + return; + } + + return config; + } catch (error) { + throw new Error(Bun.inspect(error)); + } +} + +// Deletes a guild config +export async function deleteGuildConfig(guild: string) { + try { + await db.transaction(async (query) => { + await query.delete(guildConfig).where(eq(guildConfig.guild_id, guild)); + }); + } catch (error) { + throw new Error(Bun.inspect(error)); + } +} + +// Updates or inserts a guild config +export async function updateGuildConfig(guild: string, config: HibikiGuildConfig) { + try { + // Checks for an existing config + const existingConfig = await getGuildConfig(guild); + + // Update if exists, else insert + await (existingConfig?.guild_id + ? db.update(guildConfig).set(config).where(eq(guildConfig.guild_id, guild)) + : db.insert(guildConfig).values(config).onConflictDoNothing()); + } catch (error) { + throw new Error(Bun.inspect(error)); + } +} + +// Creates a blank guild config +export async function createBlankGuildConfig(guild: string) { + try { + await db.insert(guildConfig).values({ guild_id: guild }).onConflictDoNothing(); + } catch (error) { + throw new Error(Bun.inspect(error)); + } +} diff --git a/src/db/lib/user_config.ts b/src/db/lib/user_config.ts new file mode 100644 index 000000000..ab83cf3f9 --- /dev/null +++ b/src/db/lib/user_config.ts @@ -0,0 +1,56 @@ +import { db } from "$db/db.ts"; +import { userConfig } from "$db/schema/user_config.ts"; +import type { HibikiUserConfig } from "$db/typings/index.d.ts"; +import { eq } from "drizzle-orm"; + +// Gets a user config +export async function getUserConfig(user: string) { + try { + const config = await db.query.userConfig.findFirst({ + where: (userConfig, { eq }) => eq(userConfig.user_id, user), + }); + + if (!config?.user_id) { + return; + } + + return config; + } catch (error) { + throw new Error(Bun.inspect(error)); + } +} + +// Deletes a user config +export async function deleteUserConfig(user: string) { + try { + await db.transaction(async (query) => { + await query.delete(userConfig).where(eq(userConfig.user_id, user)); + }); + } catch (error) { + throw new Error(Bun.inspect(error)); + } +} + +// Updates or inserts a user config +export async function updateUserConfig(user: string, config: HibikiUserConfig) { + try { + // Checks for an existing config + const existingConfig = await getUserConfig(user); + + // Update if exists, else insert + await (existingConfig?.user_id + ? db.update(userConfig).set(config).where(eq(userConfig.user_id, user)) + : db.insert(userConfig).values(config).onConflictDoNothing()); + } catch (error) { + throw new Error(Bun.inspect(error)); + } +} + +// Creates a blank user config +export async function createBlankUserConfig(user: string) { + try { + await db.insert(userConfig).values({ user_id: user }).onConflictDoNothing(); + } catch (error) { + throw new Error(Bun.inspect(error)); + } +} diff --git a/src/db/schema/guild_config.ts b/src/db/schema/guild_config.ts new file mode 100644 index 000000000..1acdaa947 --- /dev/null +++ b/src/db/schema/guild_config.ts @@ -0,0 +1,6 @@ +import { pgTable, text, uuid } from "drizzle-orm/pg-core"; + +export const guildConfig = pgTable("guild_config", { + id: uuid("id").primaryKey().defaultRandom(), + guild_id: text("guild_id").notNull(), +}); diff --git a/src/db/schema/user_config.ts b/src/db/schema/user_config.ts new file mode 100644 index 000000000..50190b84b --- /dev/null +++ b/src/db/schema/user_config.ts @@ -0,0 +1,6 @@ +import { pgTable, text, uuid } from "drizzle-orm/pg-core"; + +export const userConfig = pgTable("user_config", { + id: uuid("id").primaryKey().defaultRandom(), + user_id: text("user_id").notNull(), +}); diff --git a/src/db/typings/index.d.ts b/src/db/typings/index.d.ts new file mode 100644 index 000000000..e188babaa --- /dev/null +++ b/src/db/typings/index.d.ts @@ -0,0 +1,8 @@ +export interface HibikiGuildConfig { + guild_id: string; +} + +export interface HibikiUserConfig { + user_id: string; + locale?: string; +} diff --git a/src/utils/env.ts b/src/utils/env.ts index 6b4228df5..d551c7d9d 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -25,6 +25,13 @@ const envSchema = z.object({ API_GOOGLEMAPS_KEY: z.string().trim().optional(), API_GITHUB_PAT: z.string().trim().optional(), + // PostgreSQL options + POSTGRES_USER: z.string().trim().min(1, { message: "Missing PostgreSQL user" }), + POSTGRES_PASSWORD: z.string().trim().min(1, { message: "Missing PostgreSQL password" }), + POSTGRES_PORT: z.string().trim().min(1, { message: "Missing PostgreSQL port" }), + POSTGRES_HOST: z.string().trim().min(1, { message: "Missing PostgreSQL host" }), + POSTGRES_DB: z.string().trim().min(1, { message: "Missing PostgreSQL DB" }), + // Node/Bun stuff NODE_ENV: z.string().default("DEVELOPMENT"), npm_package_name: z.string().default("develop"), diff --git a/tsconfig.json b/tsconfig.json index 40635bbcc..229a9de6f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "exclude": ["./dist/*", "node_modules", "build/*"], - "include": ["./src/**/*", "./locales/*"], + "include": ["./src/**/*", "./locales/*", "drizzle.config.ts"], "compilerOptions": { "paths": { @@ -8,6 +8,7 @@ "$bot/*": ["./src/*"], "$classes/*": ["./src/classes/*"], "$commands/*": ["./src/commands/*"], + "$db/*": ["./src/db/*"], "$events/*": ["./src/events/*"], "$typings/*": ["./src/typings/*"], "$utils/*": ["./src/utils/*"] @@ -52,7 +53,7 @@ "resolveJsonModule": true, "resolvePackageJsonExports": true, "resolvePackageJsonImports": true, - "rootDir": "./src", + "rootDir": ".", "skipLibCheck": true, "sourceMap": true, "strict": true,