feat: migrate from SQLite to PostgreSQL and update database handling

This commit is contained in:
MythEclipse
2026-05-21 15:11:32 +07:00
parent 4a47855ad4
commit bfa91063ff
24 changed files with 252 additions and 181 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
DISCORD_TOKEN=
DISCORD_CLIENT_ID=
DISCORD_GUILD_ID=
DATABASE_URL=file:./data/booster-role.sqlite
DATABASE_URL=postgresql://booster_role:booster_role@localhost:5432/booster_role
BOOSTER_ROLE_ANCHOR_ROLE_ID=
+1 -6
View File
@@ -69,7 +69,7 @@ jobs:
DISCORD_TOKEN=${DISCORD_TOKEN}
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
DATABASE_URL=${DATABASE_URL:-file:/app/data/booster-role.sqlite}
DATABASE_URL=${DATABASE_URL:?DATABASE_URL is required}
BOOSTER_ROLE_ANCHOR_ROLE_ID=${BOOSTER_ROLE_ANCHOR_ROLE_ID}
EOF
@@ -81,11 +81,6 @@ jobs:
restart: unless-stopped
env_file:
- .env
volumes:
- booster-role-data:/app/data
volumes:
booster-role-data:
EOF
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin
-1
View File
@@ -9,6 +9,5 @@ COPY tsconfig.json ./
COPY src ./src
ENV NODE_ENV=production
ENV DATABASE_URL=file:/app/data/booster-role.sqlite
CMD ["bun", "run", "src/index.ts"]
+4 -3
View File
@@ -6,7 +6,7 @@ Discord bot untuk memberi role custom kosmetik ke user yang sedang boost server.
- Bun + TypeScript
- discord.js
- SQLite via `bun:sqlite` + Drizzle schema
- PostgreSQL via Drizzle ORM
- Bun test runner
## Prasyarat
@@ -30,7 +30,7 @@ Isi `.env`:
DISCORD_TOKEN=token_bot_discord
DISCORD_CLIENT_ID=client_id_aplikasi_discord
DISCORD_GUILD_ID=id_server_discord
DATABASE_URL=file:./data/booster-role.sqlite
DATABASE_URL=postgresql://booster_role:booster_role@localhost:5432/booster_role
BOOSTER_ROLE_ANCHOR_ROLE_ID=id_role_pembatas_opsional
```
@@ -45,7 +45,7 @@ bun run db:generate
bun run db:migrate
```
SQLite default tersimpan di `./data/booster-role.sqlite`.
Pastikan `DATABASE_URL` mengarah ke database PostgreSQL external sebelum menjalankan migration.
## Menjalankan bot
@@ -86,6 +86,7 @@ Command utama yang disiapkan:
- `/booster-role recolor color` - ubah warna role milik sendiri.
- `/booster-role icon image` - pasang atau ganti logo/icon role milik sendiri.
- `/booster-role delete` - hapus role milik sendiri.
- `/booster-role admin-delete user` - admin dengan permission `Administrator` bisa hapus role custom user lain. User target bisa claim lagi jika masih eligible.
## Catatan eligibility boost
+3
View File
@@ -8,6 +8,7 @@
"@discordjs/builders": "latest",
"discord.js": "latest",
"drizzle-orm": "latest",
"postgres": "latest",
},
"devDependencies": {
"@types/bun": "^1.3.14",
@@ -175,6 +176,8 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
-5
View File
@@ -5,8 +5,3 @@ services:
restart: always
env_file:
- .env
volumes:
- booster-role-data:/app/data
volumes:
booster-role-data:
+2 -2
View File
@@ -3,8 +3,8 @@ import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./src/db/migrations",
dialect: "sqlite",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "file:./data/booster-role.sqlite"
url: process.env.DATABASE_URL ?? "postgresql://booster_role:booster_role@localhost:5432/booster_role"
}
});
+2 -1
View File
@@ -14,7 +14,8 @@
"dependencies": {
"@discordjs/builders": "latest",
"discord.js": "latest",
"drizzle-orm": "latest"
"drizzle-orm": "latest",
"postgres": "latest"
},
"devDependencies": {
"@types/bun": "^1.3.14",
+1 -1
View File
@@ -11,7 +11,7 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
discordToken: requireEnv(env, "DISCORD_TOKEN"),
discordClientId: requireEnv(env, "DISCORD_CLIENT_ID"),
discordGuildId: requireEnv(env, "DISCORD_GUILD_ID"),
databaseUrl: env.DATABASE_URL ?? "file:./data/booster-role.sqlite",
databaseUrl: env.DATABASE_URL ?? "postgresql://booster_role:booster_role@localhost:5432/booster_role",
boosterRoleAnchorRoleId: env.BOOSTER_ROLE_ANCHOR_ROLE_ID ?? null
};
}
+6 -5
View File
@@ -1,9 +1,10 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
export function createDb(databaseUrl: string) {
const sqlitePath = databaseUrl.replace(/^file:/, "");
const sqlite = new Database(sqlitePath);
return drizzle(sqlite, { schema });
const client = postgres(databaseUrl);
return drizzle(client, { schema });
}
export type AppDb = ReturnType<typeof createDb>;
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE "booster_roles" (
"guild_id" text NOT NULL,
"user_id" text NOT NULL,
"role_id" text NOT NULL,
"name" text NOT NULL,
"color" text,
"icon" text,
"created_at" integer NOT NULL,
"updated_at" integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "booster_roles_guild_user_idx" ON "booster_roles" USING btree ("guild_id","user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "booster_roles_guild_role_idx" ON "booster_roles" USING btree ("guild_id","role_id");
+123
View File
@@ -0,0 +1,123 @@
{
"id": "e654a614-04fe-4432-aee1-659bc4a0475e",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.booster_roles": {
"name": "booster_roles",
"schema": "",
"columns": {
"guild_id": {
"name": "guild_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role_id": {
"name": "role_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"booster_roles_guild_user_idx": {
"name": "booster_roles_guild_user_idx",
"columns": [
{
"expression": "guild_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"booster_roles_guild_role_idx": {
"name": "booster_roles_guild_role_idx",
"columns": [
{
"expression": "guild_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "role_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1779350490110,
"tag": "0000_simple_titania",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { integer, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
export const boosterRoles = sqliteTable(
export const boosterRoles = pgTable(
"booster_roles",
{
guildId: text("guild_id").notNull(),
-23
View File
@@ -1,23 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test } from "bun:test";
import { prepareSqlitePath } from "./sqlitePath";
describe("prepareSqlitePath", () => {
test("creates parent directory for file database urls", async () => {
const root = await mkdtemp(join(tmpdir(), "booster-role-db-"));
const dbPath = join(root, "nested", "booster-role.sqlite");
expect(existsSync(join(root, "nested"))).toBe(false);
expect(prepareSqlitePath(`file:${dbPath}`)).toBe(dbPath);
expect(existsSync(join(root, "nested"))).toBe(true);
await rm(root, { recursive: true, force: true });
});
test("does not create a directory for in-memory databases", () => {
expect(prepareSqlitePath(":memory:")).toBe(":memory:");
});
});
-12
View File
@@ -1,12 +0,0 @@
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
export function prepareSqlitePath(databaseUrl: string): string {
const sqlitePath = databaseUrl.replace(/^file:/, "");
if (sqlitePath !== ":memory:") {
mkdirSync(dirname(sqlitePath), { recursive: true });
}
return sqlitePath;
}
+4 -5
View File
@@ -1,16 +1,15 @@
import { Database } from "bun:sqlite";
import type { Client } from "discord.js";
import type { AppConfig } from "../config";
import { prepareSqlitePath } from "../db/sqlitePath";
import { createDb } from "../db";
import { BoosterRoleService } from "../services/boosterRoleService";
import { BunSqliteBoosterRoleStore } from "../services/bunSqliteBoosterRoleStore";
import { DrizzleBoosterRoleStore } from "../services/drizzleBoosterRoleStore";
import { DiscordRoleRepository } from "../services/discordRoleRepository";
import { handleGuildMemberUpdate } from "./events/guildMemberUpdate";
import { handleInteraction } from "./interactionHandler";
export function attachBotHandlers(client: Client, config: AppConfig): void {
const db = new Database(prepareSqlitePath(config.databaseUrl));
const store = new BunSqliteBoosterRoleStore(db);
const db = createDb(config.databaseUrl);
const store = new DrizzleBoosterRoleStore(db);
client.on("interactionCreate", async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
+7 -1
View File
@@ -29,4 +29,10 @@ export const boosterRoleCommand = new SlashCommandBuilder()
.setDescription("Set an optional icon on your bot-managed booster role")
.addAttachmentOption((option) => option.setName("image").setDescription("Role icon image").setRequired(true))
)
.addSubcommand((command) => command.setName("delete").setDescription("Delete your bot-managed booster role"));
.addSubcommand((command) => command.setName("delete").setDescription("Delete your bot-managed booster role"))
.addSubcommand((command) =>
command
.setName("admin-delete")
.setDescription("Delete another user's bot-managed booster role")
.addUserOption((option) => option.setName("user").setDescription("User whose booster role should be deleted").setRequired(true))
);
+38 -4
View File
@@ -1,4 +1,4 @@
import { MessageFlags } from "discord.js";
import { MessageFlags, PermissionFlagsBits } from "discord.js";
import { describe, expect, test } from "bun:test";
import { handleInteraction, type BoosterRoleCommandService, type ChatInputInteractionLike } from "./interactionHandler";
@@ -8,6 +8,7 @@ class FakeInteraction implements ChatInputInteractionLike {
commandName = "booster-role";
user = { id: "user" };
guildId: string | null = "guild";
memberPermissions: { has(permission: bigint): boolean } | null = null;
replies: Reply[] = [];
constructor(private readonly subcommand: string, private readonly values: Record<string, unknown> = {}) {}
@@ -23,6 +24,7 @@ class FakeInteraction implements ChatInputInteractionLike {
options = {
getSubcommand: () => this.subcommand,
getString: (name: string) => (this.values[name] as string | null) ?? null,
getUser: (name: string) => (this.values[name] as { id: string } | null) ?? null,
getAttachment: (name: string) => (this.values[name] as { contentType: string | null; size: number; url: string } | null) ?? null
};
}
@@ -47,8 +49,8 @@ class FakeService implements BoosterRoleCommandService {
this.calls.push("icon");
}
async deleteRole(): Promise<void> {
this.calls.push("delete");
async deleteRole(input?: { userId: string }): Promise<void> {
this.calls.push(input?.userId ? `delete:${input.userId}` : "delete");
}
}
@@ -64,7 +66,7 @@ describe("handleInteraction", () => {
});
test("routes update subcommands and replies", async () => {
for (const subcommand of ["rename", "recolor", "icon", "delete"]) {
for (const subcommand of ["rename", "recolor", "icon"]) {
const interaction = new FakeInteraction(subcommand, { name: "Test", color: "#AABBCC", image: { contentType: "image/png", size: 100, url: "https://cdn.discordapp.com/icon.png" } });
const service = new FakeService();
@@ -75,6 +77,38 @@ describe("handleInteraction", () => {
}
});
test("routes own delete command", async () => {
const interaction = new FakeInteraction("delete");
const service = new FakeService();
await handleInteraction(interaction, service, { isBoosting: async () => true });
expect(service.calls).toEqual(["delete:user"]);
expect(interaction.replies[0]).toEqual({ content: "Booster role deleted.", flags: MessageFlags.Ephemeral });
});
test("routes admin delete command for target user", async () => {
const interaction = new FakeInteraction("admin-delete", { user: { id: "target-user" } });
interaction.memberPermissions = { has: (permission) => permission === PermissionFlagsBits.Administrator };
const service = new FakeService();
await handleInteraction(interaction, service, { isBoosting: async () => true });
expect(service.calls).toEqual(["delete:target-user"]);
expect(interaction.replies[0]).toEqual({ content: "Booster role deleted by admin.", flags: MessageFlags.Ephemeral });
});
test("rejects admin delete without Administrator permission", async () => {
const interaction = new FakeInteraction("admin-delete", { user: { id: "target-user" } });
interaction.memberPermissions = { has: () => false };
const service = new FakeService();
await handleInteraction(interaction, service, { isBoosting: async () => true });
expect(service.calls).toEqual([]);
expect(interaction.replies[0]).toEqual({ content: "Administrator permission is required", flags: MessageFlags.Ephemeral });
});
test("turns service errors into private replies", async () => {
const interaction = new FakeInteraction("claim", { name: "VIP" });
const service = new FakeService();
+22 -1
View File
@@ -1,15 +1,17 @@
import { MessageFlags } from "discord.js";
import { MessageFlags, PermissionFlagsBits } from "discord.js";
import type { BoosterRoleRecord, RoleIcon } from "../services/boosterRoleService";
export type ChatInputInteractionLike = {
commandName: string;
guildId: string | null;
user: { id: string };
memberPermissions: { has(permission: bigint): boolean } | null;
isChatInputCommand(): boolean;
reply(input: { content: string; flags: MessageFlags.Ephemeral }): Promise<unknown>;
options: {
getSubcommand(): string;
getString(name: string): string | null;
getUser(name: string): { id: string } | null;
getAttachment(name: string): { contentType: string | null; size: number; url: string } | null;
};
};
@@ -75,6 +77,13 @@ export async function handleInteraction(
return;
}
if (subcommand === "admin-delete") {
requireAdministrator(interaction);
await service.deleteRole({ guildId, userId: requireUser(interaction, "user").id });
await interaction.reply({ content: "Booster role deleted by admin.", flags: MessageFlags.Ephemeral });
return;
}
throw new Error("Unknown booster-role subcommand");
} catch (error) {
await interaction.reply({ content: error instanceof Error ? error.message : "Command failed", flags: MessageFlags.Ephemeral });
@@ -92,6 +101,18 @@ function requireString(interaction: ChatInputInteractionLike, name: string): str
return value;
}
function requireUser(interaction: ChatInputInteractionLike, name: string): { id: string } {
const value = interaction.options.getUser(name);
if (!value) throw new Error(`${name} is required`);
return value;
}
function requireAdministrator(interaction: ChatInputInteractionLike): void {
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
throw new Error("Administrator permission is required");
}
}
function requireIcon(interaction: ChatInputInteractionLike, name: string): RoleIcon {
const icon = optionalIcon(interaction, name);
if (!icon) throw new Error("Role icon image is required");
+5 -1
View File
@@ -20,7 +20,11 @@ describe("registerGuildCommands", () => {
expect(rest.route).toBe("/applications/client/guilds/guild/commands");
expect(rest.body).toEqual([
expect.objectContaining({ name: "booster-role", description: "Manage your cosmetic booster role" })
expect.objectContaining({
name: "booster-role",
description: "Manage your cosmetic booster role",
options: expect.arrayContaining([expect.objectContaining({ name: "admin-delete" })])
})
]);
});
});
@@ -1,26 +0,0 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { BunSqliteBoosterRoleStore } from "./bunSqliteBoosterRoleStore";
describe("BunSqliteBoosterRoleStore", () => {
test("creates schema and stores booster role records", async () => {
const db = new Database(":memory:");
const store = new BunSqliteBoosterRoleStore(db);
const record = {
guildId: "guild",
userId: "user",
roleId: "role",
name: "Test Role",
color: "#AABBCC",
icon: null,
createdAt: 1,
updatedAt: 1
};
await store.create(record);
expect(await store.findByUser("guild", "user")).toEqual(record);
await store.delete("guild", "user");
expect(await store.findByUser("guild", "user")).toBeNull();
});
});
-71
View File
@@ -1,71 +0,0 @@
import type { Database } from "bun:sqlite";
import type { BoosterRoleRecord, BoosterRoleStore } from "./boosterRoleService";
export class BunSqliteBoosterRoleStore implements BoosterRoleStore {
constructor(private readonly db: Database) {
this.db.run(`
CREATE TABLE IF NOT EXISTS booster_roles (
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(guild_id, user_id),
UNIQUE(guild_id, role_id)
)
`);
}
async findByUser(guildId: string, userId: string): Promise<BoosterRoleRecord | null> {
const row = this.db
.query<BoosterRoleRow, [string, string]>(`
SELECT guild_id, user_id, role_id, name, color, icon, created_at, updated_at
FROM booster_roles
WHERE guild_id = ? AND user_id = ?
LIMIT 1
`)
.get(guildId, userId);
return row ? toRecord(row) : null;
}
async create(record: BoosterRoleRecord): Promise<void> {
this.db
.query<unknown, [string, string, string, string, string | null, string | null, number, number]>(`
INSERT INTO booster_roles (guild_id, user_id, role_id, name, color, icon, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
.run(record.guildId, record.userId, record.roleId, record.name, record.color, record.icon, record.createdAt, record.updatedAt);
}
async delete(guildId: string, userId: string): Promise<void> {
this.db.query<unknown, [string, string]>("DELETE FROM booster_roles WHERE guild_id = ? AND user_id = ?").run(guildId, userId);
}
}
type BoosterRoleRow = {
guild_id: string;
user_id: string;
role_id: string;
name: string;
color: string | null;
icon: string | null;
created_at: number;
updated_at: number;
};
function toRecord(row: BoosterRoleRow): BoosterRoleRecord {
return {
guildId: row.guild_id,
userId: row.user_id,
roleId: row.role_id,
name: row.name,
color: row.color,
icon: row.icon,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
@@ -11,18 +11,14 @@ type DatabaseLike = {
};
};
insert(table: typeof boosterRoles): {
values(record: BoosterRoleRecord): {
run(): unknown;
};
values(record: BoosterRoleRecord): Promise<unknown> | unknown;
};
delete(table: typeof boosterRoles): {
where(condition: unknown): {
run(): unknown;
};
where(condition: unknown): Promise<unknown> | unknown;
};
};
export class SqliteBoosterRoleStore implements BoosterRoleStore {
export class DrizzleBoosterRoleStore implements BoosterRoleStore {
constructor(private readonly db: DatabaseLike) {}
async findByUser(guildId: string, userId: string): Promise<BoosterRoleRecord | null> {
@@ -36,13 +32,12 @@ export class SqliteBoosterRoleStore implements BoosterRoleStore {
}
async create(record: BoosterRoleRecord): Promise<void> {
await this.db.insert(boosterRoles).values(record).run();
await this.db.insert(boosterRoles).values(record);
}
async delete(guildId: string, userId: string): Promise<void> {
await this.db
.delete(boosterRoles)
.where(and(eq(boosterRoles.guildId, guildId), eq(boosterRoles.userId, userId)))
.run();
.where(and(eq(boosterRoles.guildId, guildId), eq(boosterRoles.userId, userId)));
}
}