From 8a712cff9bf59d206cabe062bef42166fbbf0fc8 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sun, 31 May 2026 15:46:18 +0700 Subject: [PATCH] Refactor database handling to exclusively support PostgreSQL - Removed SQLite support from the configuration and database initialization logic. - Updated database migration scripts to focus solely on PostgreSQL migrations. - Simplified logging messages to reflect PostgreSQL usage. - Adjusted database schema definitions to remove SQLite-specific types and structures. - Modified tests to ensure compatibility with PostgreSQL, including changes to table creation and data types. - Cleaned up unused imports and code related to SQLite. --- .env.example | 6 +- drizzle.config.ts | 26 +- package.json | 2 - pnpm-lock.yaml | 63 +-- src/app/bootstrap.ts | 2 +- src/config.ts | 16 +- src/database/drizzle.ts | 144 +++---- src/database/migrate.ts | 41 +- src/database/schema.ts | 393 +------------------ src/moderation/analyticsStore.ts | 30 +- src/muxer-queue.ts | 2 +- tests/database.test.ts | 7 +- tests/database/migrate.test.ts | 21 +- tests/moderation/messageCapture.test.ts | 25 +- tests/moderation/messageStoreQueries.test.ts | 56 ++- 15 files changed, 175 insertions(+), 659 deletions(-) diff --git a/.env.example b/.env.example index 7fe7459..7d435cd 100644 --- a/.env.example +++ b/.env.example @@ -52,11 +52,7 @@ AI_LLM_MODEL=free # NVIDIA Nemotron Content Safety Configuration NVIDIA_NEMOTRON_API_KEY=your_nvidia_api_key_here -# Database Configuration -DATABASE_TYPE=sqlite -# DATABASE_TYPE=postgres - -# PostgreSQL Configuration (used when DATABASE_TYPE=postgres) +# Database Configuration (PostgreSQL) # Option 1: Use DATABASE_URL for connection string # DATABASE_URL=postgresql://user:password@localhost:5432/discord_bot diff --git a/drizzle.config.ts b/drizzle.config.ts index 6dea44f..5a33091 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,24 +1,18 @@ import { defineConfig } from "drizzle-kit"; -const databaseType = process.env.DATABASE_TYPE || "sqlite"; const databaseUrl = process.env.DATABASE_URL; export default defineConfig({ schema: "./src/database/schema.ts", out: "./drizzle/migrations", - dialect: databaseType === "postgres" ? "postgresql" : "sqlite", - dbCredentials: - databaseType === "postgres" - ? databaseUrl - ? { url: databaseUrl } - : { - host: process.env.POSTGRES_HOST || "localhost", - port: parseInt(process.env.POSTGRES_PORT || "5432", 10), - user: process.env.POSTGRES_USER || "postgres", - password: process.env.POSTGRES_PASSWORD || "", - database: process.env.POSTGRES_DB || "moderation_bot", - } - : { - url: "file:./.muxer-queue.db", - }, + dialect: "postgresql", + dbCredentials: databaseUrl + ? { url: databaseUrl } + : { + host: process.env.POSTGRES_HOST || "localhost", + port: parseInt(process.env.POSTGRES_PORT || "5432", 10), + user: process.env.POSTGRES_USER || "postgres", + password: process.env.POSTGRES_PASSWORD || "", + database: process.env.POSTGRES_DB || "moderation_bot", + }, }); diff --git a/package.json b/package.json index 9e69d68..94e1c80 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@types/pg": "^8.20.0", "@vitejs/plugin-react": "^6.0.2", "axios": "^1.16.1", - "better-sqlite3": "^12.10.0", "clsx": "^2.1.1", "discord.js-selfbot-v13": "workspace:*", "dotenv": "^17.4.2", @@ -67,7 +66,6 @@ "devDependencies": { "@biomejs/biome": "latest", "@tailwindcss/postcss": "^4.3.0", - "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", "@types/node": "^25.9.0", "@types/react": "^19.2.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5825cda..cbd32cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,13 +37,10 @@ importers: version: 8.20.0 '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) axios: specifier: ^1.16.1 version: 1.16.1 - better-sqlite3: - specifier: ^12.10.0 - version: 12.10.0 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -55,7 +52,7 @@ importers: version: 17.4.2 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(pg@8.21.0) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0) express: specifier: ^5.2.1 version: 5.2.1 @@ -109,7 +106,7 @@ importers: version: 3.6.0 vite: specifier: ^8.0.13 - version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) + version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) winston: specifier: ^3.19.0 version: 3.19.0 @@ -126,9 +123,6 @@ importers: '@tailwindcss/postcss': specifier: ^4.3.0 version: 4.3.0 - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 '@types/express': specifier: ^5.0.6 version: 5.0.6 @@ -164,7 +158,7 @@ importers: version: 5.9.3 vitest: specifier: latest - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) vendor/discord-video-stream: dependencies: @@ -1954,9 +1948,6 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - '@types/better-sqlite3@7.6.13': - resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2239,13 +2230,6 @@ packages: before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} - better-sqlite3@12.10.0: - resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} - engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} - - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - bintrees@1.0.2: resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} @@ -2914,9 +2898,6 @@ packages: resolution: {integrity: sha512-B/SdeSIeRv7VlOgIjtH3dkxMI+tEy5m+OeCXfAUsirBoVoY+bGtsmvmmTFPm/G23TBY4RiTtjpcgePCfwXRjqA==} engines: {node: '>=8'} - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -6288,10 +6269,6 @@ snapshots: tslib: 2.8.1 optional: true - '@types/better-sqlite3@7.6.13': - dependencies: - '@types/node': 25.8.0 - '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -6396,10 +6373,10 @@ snapshots: dependencies: '@types/node': 25.8.0 - '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) '@vitest/expect@4.1.7': dependencies: @@ -6410,13 +6387,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))': + '@vitest/mocker@4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) '@vitest/pretty-format@4.1.7': dependencies: @@ -6570,15 +6547,6 @@ snapshots: before-after-hook@2.2.3: {} - better-sqlite3@12.10.0: - dependencies: - bindings: 1.5.0 - prebuild-install: 7.1.3 - - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - bintrees@1.0.2: {} bl@4.1.0: @@ -6929,12 +6897,10 @@ snapshots: esbuild: 0.25.12 tsx: 4.22.1 - drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(pg@8.21.0): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0): optionalDependencies: '@opentelemetry/api': 1.9.1 - '@types/better-sqlite3': 7.6.13 '@types/pg': 8.20.0 - better-sqlite3: 12.10.0 pg: 8.21.0 dunder-proto@1.0.1: @@ -7289,8 +7255,6 @@ snapshots: array-back: 4.0.2 glob: 7.2.3 - file-uri-to-path@1.0.0: {} - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -9088,7 +9052,7 @@ snapshots: vary@1.1.2: {} - vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0): + vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -9101,12 +9065,11 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.22.2 - yaml: 2.9.0 - vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)): + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -9123,7 +9086,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index 943710f..323e409 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -42,7 +42,7 @@ export async function initializeApp() { try { logger.info("Initializing database"); await initializeDatabase(); - logger.info({ type: config.DATABASE_TYPE }, "Database initialized"); + logger.info("PostgreSQL database initialized"); } catch (err) { logger.error({ error: err }, "Failed to initialize database"); process.exit(1); diff --git a/src/config.ts b/src/config.ts index 9ea4970..95accac 100644 --- a/src/config.ts +++ b/src/config.ts @@ -155,7 +155,6 @@ const configSchema = z .optional() .transform((v) => v === "true") .default(true), - DATABASE_TYPE: z.enum(["sqlite", "postgres"]).default("sqlite"), DATABASE_URL: z.string().optional(), POSTGRES_HOST: z.string().default("localhost"), POSTGRES_PORT: z.coerce.number().int().positive().default(5432), @@ -179,15 +178,12 @@ const configSchema = z } // Validate PostgreSQL configuration - if (value.DATABASE_TYPE === "postgres") { - if (!value.DATABASE_URL && !value.POSTGRES_HOST) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["DATABASE_URL"], - message: - "Either DATABASE_URL or POSTGRES_HOST must be provided when DATABASE_TYPE=postgres", - }); - } + if (!value.DATABASE_URL && !value.POSTGRES_HOST) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["DATABASE_URL"], + message: "Either DATABASE_URL or POSTGRES_HOST must be provided", + }); } }); diff --git a/src/database/drizzle.ts b/src/database/drizzle.ts index ff8916f..60f3bd0 100644 --- a/src/database/drizzle.ts +++ b/src/database/drizzle.ts @@ -1,5 +1,3 @@ -import Database from "better-sqlite3"; -import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3"; import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { config } from "../config.js"; @@ -8,80 +6,54 @@ import * as schema from "./schema.js"; const logger = createChildLogger("drizzle"); -let db: - | ReturnType - | ReturnType - | null = null; -let rawSqlite: ReturnType | null = null; +let db: ReturnType | null = null; let rawPool: Pool | null = null; /** - * Initialize the database connection based on DATABASE_TYPE config - * Supports both PostgreSQL and SQLite + * Initialize the PostgreSQL database connection. */ export async function initializeDatabase() { if (db !== null) { return db; } - // During tests prefer an isolated SQLite instance to avoid using shared - // external Postgres instances which can lead to flaky test interference. - const usePostgres = - config.DATABASE_TYPE === "postgres" && process.env.NODE_ENV !== "test"; + let pool: Pool; - if (usePostgres) { - let pool: Pool; - - // Use DATABASE_URL if available, otherwise build from individual variables - if (config.DATABASE_URL) { - pool = new Pool({ - connectionString: config.DATABASE_URL, - min: config.POSTGRES_POOL_MIN, - max: config.POSTGRES_POOL_MAX, - }); - } else { - pool = new Pool({ - host: config.POSTGRES_HOST, - port: config.POSTGRES_PORT, - user: config.POSTGRES_USER, - password: config.POSTGRES_PASSWORD, - database: config.POSTGRES_DB, - min: config.POSTGRES_POOL_MIN, - max: config.POSTGRES_POOL_MAX, - }); - } - - rawPool = pool; - db = drizzlePostgres(pool, { schema }); - // Provide a simple `run` helper for tests that expect it. - try { - (db as any).run = (sql: string) => pool.query(sql); - } catch { - // ignore - } - logger.info("PostgreSQL database initialized"); + if (config.DATABASE_URL) { + pool = new Pool({ + connectionString: config.DATABASE_URL, + min: config.POSTGRES_POOL_MIN, + max: config.POSTGRES_POOL_MAX, + }); } else { - const sqlite = new Database(".muxer-queue.db"); - sqlite.pragma("journal_mode = WAL"); - - rawSqlite = sqlite; - db = drizzleSqlite(sqlite, { schema }); - // Expose a convenience `run` method used by tests that expect a simple API. - // `sqlite` is the underlying better-sqlite3 Database instance. - try { - (db as any).run = (sql: string) => sqlite.exec(sql); - } catch { - // ignore - } - logger.info("SQLite database initialized"); + pool = new Pool({ + host: config.POSTGRES_HOST, + port: config.POSTGRES_PORT, + user: config.POSTGRES_USER, + password: config.POSTGRES_PASSWORD, + database: config.POSTGRES_DB, + min: config.POSTGRES_POOL_MIN, + max: config.POSTGRES_POOL_MAX, + }); } + rawPool = pool; + db = drizzlePostgres(pool, { schema }); + + try { + (db as { run?: (sql: string) => Promise }).run = (sql: string) => + pool.query(sql); + } catch { + // ignore + } + + logger.info("PostgreSQL database initialized"); return db; } /** - * Get the initialized database instance - * Throws if database has not been initialized + * Get the initialized database instance. + * Throws if database has not been initialized. */ export function getDatabase() { if (db === null) { @@ -97,51 +69,39 @@ function convertPlaceholdersForPostgres(sql: string) { return sql.replace(/\?/g, () => `$${++i}`); } -export async function executeAll(sql: string, params?: any[]) { - if (rawPool) { - const q = convertPlaceholdersForPostgres(sql); - const res = await rawPool.query(q, params || []); - return res.rows; +export async function executeAll(sql: string, params?: unknown[]) { + if (!rawPool) { + throw new Error( + "Database not initialized. Call initializeDatabase() first.", + ); } - if (rawSqlite) { - const stmt = rawSqlite.prepare(sql); - return stmt.all(...(params || [])); - } - - throw new Error("Database not initialized. Call initializeDatabase() first."); + const query = convertPlaceholdersForPostgres(sql); + const result = await rawPool.query(query, params || []); + return result.rows; } -export async function executeGet(sql: string, params?: any[]) { - if (rawPool) { - const q = convertPlaceholdersForPostgres(sql); - const res = await rawPool.query(q, params || []); - return res.rows[0] ?? null; +export async function executeGet(sql: string, params?: unknown[]) { + if (!rawPool) { + throw new Error( + "Database not initialized. Call initializeDatabase() first.", + ); } - if (rawSqlite) { - const stmt = rawSqlite.prepare(sql); - return stmt.get(...(params || [])); - } - - throw new Error("Database not initialized. Call initializeDatabase() first."); + const query = convertPlaceholdersForPostgres(sql); + const result = await rawPool.query(query, params || []); + return result.rows[0] ?? null; } /** - * Close the database connection - * For PostgreSQL, the pool will close on process exit - * For SQLite, closes the database connection + * Close the PostgreSQL connection pool. */ export async function closeDatabase() { - if (db === null) { - return; - } - - if (config.DATABASE_TYPE === "postgres") { - logger.info("PostgreSQL connection pool will close on process exit"); - } else { - logger.info("SQLite database closed"); + if (rawPool !== null) { + await rawPool.end(); } + rawPool = null; db = null; + logger.info("PostgreSQL database closed"); } diff --git a/src/database/migrate.ts b/src/database/migrate.ts index 92ccaff..f94a39b 100644 --- a/src/database/migrate.ts +++ b/src/database/migrate.ts @@ -1,45 +1,24 @@ import "dotenv/config"; -import Database from "better-sqlite3"; -import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3"; -import { migrate as migrateSqlite } from "drizzle-orm/better-sqlite3/migrator"; import { migrate as migratePostgres } from "drizzle-orm/node-postgres/migrator"; -import { config } from "../config.js"; import { createChildLogger } from "../logger.js"; import { closeDatabase, initializeDatabase } from "./drizzle.js"; const logger = createChildLogger("migrate"); -export function initializeMigrationSqliteDatabase(path = ".muxer-queue.db") { - const sqlite = new Database(path); - sqlite.pragma("journal_mode = WAL"); - return { sqlite, db: drizzleSqlite(sqlite) }; -} - export async function runMigrations(): Promise { try { - logger.info("Starting database migrations"); + logger.info("Starting PostgreSQL migrations"); + const db = (await initializeDatabase()) as Parameters< + typeof migratePostgres + >[0]; - if (config.DATABASE_TYPE === "postgres") { - logger.info("Running PostgreSQL migrations"); - const db = (await initializeDatabase()) as Parameters< - typeof migratePostgres - >[0]; - try { - await migratePostgres(db, { migrationsFolder: "./drizzle/migrations" }); - } finally { - await closeDatabase(); - } - logger.info("PostgreSQL migrations completed successfully"); - } else { - logger.info("Running SQLite migrations"); - const { sqlite, db } = initializeMigrationSqliteDatabase(); - try { - migrateSqlite(db, { migrationsFolder: "./drizzle/migrations" }); - } finally { - sqlite.close(); - } - logger.info("SQLite migrations completed successfully"); + try { + await migratePostgres(db, { migrationsFolder: "./drizzle/migrations" }); + } finally { + await closeDatabase(); } + + logger.info("PostgreSQL migrations completed successfully"); } catch (error) { logger.error( { error: error instanceof Error ? error.message : String(error) }, diff --git a/src/database/schema.ts b/src/database/schema.ts index eb55396..e85133a 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -8,14 +8,6 @@ import { pgTable, text as pgText, } from "drizzle-orm/pg-core"; -import { - index as sqliteIndex, - integer as sqliteInteger, - real as sqliteReal, - sqliteTable, - text as sqliteText, -} from "drizzle-orm/sqlite-core"; -import { config } from "../config.js"; // PostgreSQL Schema // ================== @@ -244,227 +236,6 @@ export const pgVoiceRecordingsTable = pgTable( }), ); -// SQLite Schema -// ============= - -/** - * Muxer Jobs Table (SQLite) - * Tracks audio post-processing jobs with status and retry logic - */ -export const sqliteMuxerJobsTable = sqliteTable( - "muxer_jobs", - { - id: sqliteText("id").primaryKey(), - data: sqliteText("data").notNull(), - status: sqliteText("status", { - enum: ["pending", "processing", "completed", "failed"], - }) - .notNull() - .default("pending"), - attempts: sqliteInteger("attempts").notNull().default(0), - maxAttempts: sqliteInteger("maxAttempts").notNull().default(3), - createdAt: sqliteInteger("createdAt").notNull(), - updatedAt: sqliteInteger("updatedAt").notNull(), - error: sqliteText("error"), - }, - (table) => ({ - statusIdx: sqliteIndex("idx_muxer_jobs_status").on(table.status), - createdAtIdx: sqliteIndex("idx_muxer_jobs_createdAt").on(table.createdAt), - }), -); - -/** - * Messages Table (SQLite) - * Stores text messages with AI moderation analysis - */ -export const sqliteMessagesTable = sqliteTable( - "messages", - { - id: sqliteText("id").primaryKey(), - guild_id: sqliteText("guild_id").notNull(), - channel_id: sqliteText("channel_id").notNull(), - thread_id: sqliteText("thread_id"), - user_id: sqliteText("user_id").notNull(), - username: sqliteText("username").notNull(), - avatar_url: sqliteText("avatar_url"), - content: sqliteText("content").notNull(), - edited_content: sqliteText("edited_content"), - created_at: sqliteInteger("created_at").notNull(), - edited_at: sqliteInteger("edited_at"), - deleted_at: sqliteInteger("deleted_at"), - type: sqliteText("type", { enum: ["text", "edited", "deleted"] }) - .notNull() - .default("text"), - metadata: sqliteText("metadata"), - ai_status: sqliteText("ai_status", { - enum: ["pending", "clean", "warn", "flagged", "error"], - }) - .notNull() - .default("pending"), - ai_moderation_flags: sqliteText("ai_moderation_flags"), - ai_moderation_score: sqliteReal("ai_moderation_score"), - ai_analysis: sqliteText("ai_analysis"), - ai_categories: sqliteText("ai_categories"), - ai_severity: sqliteText("ai_severity", { - enum: ["none", "low", "medium", "high", "critical"], - }), - ai_confidence: sqliteReal("ai_confidence"), - ai_recommended_action: sqliteText("ai_recommended_action", { - enum: ["none", "monitor", "warn", "review", "delete", "escalate"], - }), - ai_analyzed_at: sqliteInteger("ai_analyzed_at"), - ai_error: sqliteText("ai_error"), - }, - (table) => ({ - channelIdx: sqliteIndex("idx_messages_channel").on(table.channel_id), - userIdx: sqliteIndex("idx_messages_user").on(table.user_id), - createdIdx: sqliteIndex("idx_messages_created").on(table.created_at), - threadIdx: sqliteIndex("idx_messages_thread").on(table.thread_id), - channelCreatedIdx: sqliteIndex("idx_messages_channel_created").on( - table.channel_id, - table.created_at, - table.id, - ), - threadCreatedIdx: sqliteIndex("idx_messages_thread_created").on( - table.thread_id, - table.created_at, - table.id, - ), - aiStatusCreatedIdx: sqliteIndex("idx_messages_ai_status_created").on( - table.ai_status, - table.created_at, - table.id, - ), - guildAiStatusCreatedIdx: sqliteIndex( - "idx_messages_guild_ai_status_created", - ).on(table.guild_id, table.ai_status, table.created_at, table.id), - }), -); - -/** - * Attachments Table (SQLite) - * Stores attachment metadata with upload status tracking - */ -export const sqliteAttachmentsTable = sqliteTable( - "attachments", - { - id: sqliteText("id").primaryKey(), - message_id: sqliteText("message_id").notNull(), - guild_id: sqliteText("guild_id").notNull(), - channel_id: sqliteText("channel_id").notNull(), - thread_id: sqliteText("thread_id"), - user_id: sqliteText("user_id").notNull(), - filename: sqliteText("filename").notNull(), - size: sqliteInteger("size").notNull(), - type: sqliteText("type").notNull(), - discord_url: sqliteText("discord_url").notNull(), - uploaded_url: sqliteText("uploaded_url"), - upload_status: sqliteText("upload_status", { - enum: ["pending", "uploaded", "failed"], - }) - .notNull() - .default("pending"), - upload_error: sqliteText("upload_error"), - created_at: sqliteInteger("created_at").notNull(), - uploaded_at: sqliteInteger("uploaded_at"), - }, - (table) => ({ - channelIdx: sqliteIndex("idx_attachments_channel").on(table.channel_id), - messageIdx: sqliteIndex("idx_attachments_message").on(table.message_id), - statusIdx: sqliteIndex("idx_attachments_status").on(table.upload_status), - channelCreatedIdx: sqliteIndex("idx_attachments_channel_created").on( - table.channel_id, - table.created_at, - table.id, - ), - threadCreatedIdx: sqliteIndex("idx_attachments_thread_created").on( - table.thread_id, - table.created_at, - table.id, - ), - }), -); - -/** - * UI State Table (SQLite) - * Stores persistent UI state (e.g., selected channel, filter preferences) - */ -export const sqliteUIStateTable = sqliteTable("ui_state", { - key: sqliteText("key").primaryKey(), - value: sqliteText("value").notNull(), - updated_at: sqliteInteger("updated_at").notNull(), -}); - -/** - * AI Analysis Runs Table (SQLite) - * Tracks AI analysis batch runs for conversation-level moderation - */ -export const sqliteAIAnalysisRunsTable = sqliteTable( - "ai_analysis_runs", - { - id: sqliteText("id").primaryKey(), - conversation_key: sqliteText("conversation_key").notNull(), - target_message_ids: sqliteText("target_message_ids").notNull(), // JSON array - model: sqliteText("model").notNull(), - request_tokens_estimate: sqliteInteger("request_tokens_estimate"), - response_raw: sqliteText("response_raw"), - status: sqliteText("status", { - enum: ["pending", "processing", "completed", "failed"], - }) - .notNull() - .default("pending"), - error: sqliteText("error"), - created_at: sqliteInteger("created_at").notNull(), - completed_at: sqliteInteger("completed_at"), - }, - (table) => ({ - conversationKeyIdx: sqliteIndex("idx_ai_analysis_runs_conversation_key").on( - table.conversation_key, - ), - statusIdx: sqliteIndex("idx_ai_analysis_runs_status").on(table.status), - createdAtIdx: sqliteIndex("idx_ai_analysis_runs_created_at").on( - table.created_at, - ), - }), -); - -/** - * Voice Recordings Table (SQLite) - * Stores voice recording segment metadata and upload status - */ -export const sqliteVoiceRecordingsTable = sqliteTable( - "voice_recordings", - { - id: sqliteText("id").primaryKey(), - user_id: sqliteText("user_id").notNull(), - username: sqliteText("username").notNull(), - avatar_url: sqliteText("avatar_url"), - guild_id: sqliteText("guild_id"), - channel_id: sqliteText("channel_id"), - channel_name: sqliteText("channel_name"), - filename: sqliteText("filename").notNull(), - size_bytes: sqliteInteger("size_bytes").notNull(), - download_url: sqliteText("download_url"), - upload_status: sqliteText("upload_status", { - enum: ["pending", "uploaded", "failed"], - }) - .notNull() - .default("pending"), - upload_error: sqliteText("upload_error"), - created_at: sqliteInteger("created_at").notNull(), - uploaded_at: sqliteInteger("uploaded_at"), - }, - (table) => ({ - userIdIdx: sqliteIndex("idx_voice_recordings_user_id").on(table.user_id), - channelIdIdx: sqliteIndex("idx_voice_recordings_channel_id").on( - table.channel_id, - ), - createdIdx: sqliteIndex("idx_voice_recordings_created_at").on( - table.created_at, - ), - }), -); - /** * Message Reviews Table (PostgreSQL) * Tracks manual reviews of messages flagged by AI moderation @@ -502,43 +273,6 @@ export const pgMessageReviewsTable = pgTable( }), ); -/** - * Message Reviews Table (SQLite) - * Tracks manual reviews of messages flagged by AI moderation - */ -export const sqliteMessageReviewsTable = sqliteTable( - "message_reviews", - { - id: sqliteText("id").primaryKey(), - message_id: sqliteText("message_id").notNull(), - guild_id: sqliteText("guild_id").notNull(), - channel_id: sqliteText("channel_id").notNull(), - reviewer_id: sqliteText("reviewer_id"), - status: sqliteText("status", { - enum: ["pending", "approved", "rejected", "escalated"], - }) - .notNull() - .default("pending"), - notes: sqliteText("notes"), - created_at: sqliteInteger("created_at").notNull(), - reviewed_at: sqliteInteger("reviewed_at"), - }, - (table) => ({ - messageIdIdx: sqliteIndex("idx_message_reviews_message_id").on( - table.message_id, - ), - statusIdx: sqliteIndex("idx_message_reviews_status").on(table.status), - createdAtIdx: sqliteIndex("idx_message_reviews_created_at").on( - table.created_at, - ), - guildStatusIdx: sqliteIndex("idx_message_reviews_guild_status").on( - table.guild_id, - table.status, - table.created_at, - ), - }), -); - /** * Moderation Actions Table (PostgreSQL) * Tracks actions taken on messages (delete, mute, etc.) @@ -584,51 +318,6 @@ export const pgModerationActionsTable = pgTable( }), ); -/** - * Moderation Actions Table (SQLite) - * Tracks actions taken on messages (delete, mute, etc.) - */ -export const sqliteModerationActionsTable = sqliteTable( - "moderation_actions", - { - id: sqliteText("id").primaryKey(), - message_id: sqliteText("message_id"), - user_id: sqliteText("user_id"), - guild_id: sqliteText("guild_id").notNull(), - action_type: sqliteText("action_type", { - enum: [ - "delete_message", - "mute_user", - "warn_user", - "kick_user", - "ban_user", - ], - }).notNull(), - reason: sqliteText("reason"), - executed_by: sqliteText("executed_by"), - status: sqliteText("status", { - enum: ["pending", "executed", "failed"], - }) - .notNull() - .default("pending"), - error: sqliteText("error"), - created_at: sqliteInteger("created_at").notNull(), - executed_at: sqliteInteger("executed_at"), - }, - (table) => ({ - messageIdIdx: sqliteIndex("idx_moderation_actions_message_id").on( - table.message_id, - ), - userIdIdx: sqliteIndex("idx_moderation_actions_user_id").on(table.user_id), - statusIdx: sqliteIndex("idx_moderation_actions_status").on(table.status), - guildStatusIdx: sqliteIndex("idx_moderation_actions_guild_status").on( - table.guild_id, - table.status, - table.created_at, - ), - }), -); - /** * Retention Policies Table (PostgreSQL) * Defines data retention rules per guild/channel @@ -652,78 +341,18 @@ export const pgRetentionPoliciesTable = pgTable( }), ); -/** - * Retention Policies Table (SQLite) - * Defines data retention rules per guild/channel - */ -export const sqliteRetentionPoliciesTable = sqliteTable( - "retention_policies", - { - id: sqliteText("id").primaryKey(), - guild_id: sqliteText("guild_id").notNull(), - channel_id: sqliteText("channel_id"), - retention_days: sqliteInteger("retention_days").notNull().default(90), - apply_to_media: sqliteInteger("apply_to_media", { mode: "boolean" }) - .notNull() - .default(true), - apply_to_voice: sqliteInteger("apply_to_voice", { mode: "boolean" }) - .notNull() - .default(true), - enabled: sqliteInteger("enabled", { mode: "boolean" }) - .notNull() - .default(true), - created_at: sqliteInteger("created_at").notNull(), - updated_at: sqliteInteger("updated_at").notNull(), - }, - (table) => ({ - guildIdIdx: sqliteIndex("idx_retention_policies_guild_id").on( - table.guild_id, - ), - enabledIdx: sqliteIndex("idx_retention_policies_enabled").on(table.enabled), - }), -); +// Runtime table exports +// ===================== -// Runtime table selection based on config -// ======================================== - -export const muxerJobsTable = - config.DATABASE_TYPE === "postgres" ? pgMuxerJobsTable : sqliteMuxerJobsTable; - -export const messagesTable = - config.DATABASE_TYPE === "postgres" ? pgMessagesTable : sqliteMessagesTable; - -export const attachmentsTable = - config.DATABASE_TYPE === "postgres" - ? pgAttachmentsTable - : sqliteAttachmentsTable; - -export const uiStateTable = - config.DATABASE_TYPE === "postgres" ? pgUIStateTable : sqliteUIStateTable; - -export const aiAnalysisRunsTable = - config.DATABASE_TYPE === "postgres" - ? pgAIAnalysisRunsTable - : sqliteAIAnalysisRunsTable; - -export const voiceRecordingsTable = - config.DATABASE_TYPE === "postgres" - ? pgVoiceRecordingsTable - : sqliteVoiceRecordingsTable; - -export const messageReviewsTable = - config.DATABASE_TYPE === "postgres" - ? pgMessageReviewsTable - : sqliteMessageReviewsTable; - -export const moderationActionsTable = - config.DATABASE_TYPE === "postgres" - ? pgModerationActionsTable - : sqliteModerationActionsTable; - -export const retentionPoliciesTable = - config.DATABASE_TYPE === "postgres" - ? pgRetentionPoliciesTable - : sqliteRetentionPoliciesTable; +export const muxerJobsTable = pgMuxerJobsTable; +export const messagesTable = pgMessagesTable; +export const attachmentsTable = pgAttachmentsTable; +export const uiStateTable = pgUIStateTable; +export const aiAnalysisRunsTable = pgAIAnalysisRunsTable; +export const voiceRecordingsTable = pgVoiceRecordingsTable; +export const messageReviewsTable = pgMessageReviewsTable; +export const moderationActionsTable = pgModerationActionsTable; +export const retentionPoliciesTable = pgRetentionPoliciesTable; // Export table types for use in queries export type MuxerJob = typeof muxerJobsTable.$inferSelect; diff --git a/src/moderation/analyticsStore.ts b/src/moderation/analyticsStore.ts index 0c77370..1699dde 100644 --- a/src/moderation/analyticsStore.ts +++ b/src/moderation/analyticsStore.ts @@ -1,4 +1,3 @@ -import { config } from "../config.js"; import { executeAll, executeGet } from "../database/drizzle.js"; import { createChildLogger } from "../logger.js"; import type { MessageRecord } from "./types.js"; @@ -107,11 +106,7 @@ export async function getHourlyStats(input: { try { const since = Date.now() - hours * 3600_000; - const isPg = config.DATABASE_TYPE === "postgres"; - - const hourExpr = isPg - ? `to_char(to_timestamp((created_at / 3600000) * 3600), 'YYYY-MM-DD HH24:MI:SS') as hour` - : `datetime((created_at / 3600000) * 3600, 'unixepoch') as hour`; + const hourExpr = `to_char(to_timestamp((created_at / 3600000) * 3600), 'YYYY-MM-DD HH24:MI:SS') as hour`; const rows = await executeAll( ` @@ -531,11 +526,7 @@ export async function getModerationStats(input: { try { const since = Date.now() - hours * 3600_000; - const isPg = config.DATABASE_TYPE === "postgres"; - - const avgScoreExpr = isPg - ? `round(avg(ai_moderation_score)::numeric, 2)` - : `round(avg(ai_moderation_score), 2)`; + const avgScoreExpr = `round(avg(ai_moderation_score)::numeric, 2)`; const row = await executeGet( ` @@ -738,11 +729,7 @@ export async function getDailyTrend(input: { try { const since = Date.now() - hours * 3600_000; - const isPg = config.DATABASE_TYPE === "postgres"; - - const dateExpr = isPg - ? `to_char(date_trunc('day', to_timestamp(created_at / 1000)), 'YYYY-MM-DD') as date` - : `date(created_at / 1000, 'unixepoch') as date`; + const dateExpr = `to_char(date_trunc('day', to_timestamp(created_at / 1000)), 'YYYY-MM-DD') as date`; const rows = await executeAll( ` @@ -832,15 +819,8 @@ export async function getActivityHeatmap(input: { try { const since = Date.now() - hours * 3600_000; - const isPg = config.DATABASE_TYPE === "postgres"; - - // SQLite: cast to int for modulo; Postgres: use extract() - const dayExpr = isPg - ? `(extract(isodow from to_timestamp(created_at / 1000)) % 7)::int as day_of_week` - : `(cast((created_at / 86400000) as integer) % 7) as day_of_week`; - const hourExpr = isPg - ? `extract(hour from to_timestamp(created_at / 1000))::int as hour` - : `(cast((created_at / 3600000) as integer) % 24) as hour`; + const dayExpr = `(extract(isodow from to_timestamp(created_at / 1000)) % 7)::int as day_of_week`; + const hourExpr = `extract(hour from to_timestamp(created_at / 1000))::int as hour`; const rows = await executeAll( ` diff --git a/src/muxer-queue.ts b/src/muxer-queue.ts index 6eef081..f60026e 100644 --- a/src/muxer-queue.ts +++ b/src/muxer-queue.ts @@ -53,7 +53,7 @@ export function getDatabase() { return undefined as unknown as never; } -// ── Persistent KV store (replaces SQLite uiState table) ──────────────────── +// ── Persistent KV store ──────────────────────────────────────────────────── const KV_PREFIX = "kv:"; diff --git a/tests/database.test.ts b/tests/database.test.ts index 969ec01..ac1a62d 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -4,7 +4,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; const originalEnv = process.env; describe("Drizzle ORM Database", () => { - let config: typeof import("../src/config").config; let drizzle: typeof import("../src/database/drizzle"); let logger: ReturnType; @@ -14,22 +13,20 @@ describe("Drizzle ORM Database", () => { ...originalEnv, DISCORD_TOKEN: "test-token", NODE_ENV: "test", - DATABASE_TYPE: originalEnv.DATABASE_TYPE || "sqlite", }; // Reset modules to pick up new environment vi.resetModules(); // Import after environment is set - const configModule = await import("../src/config"); + await import("../src/config"); const drizzleModule = await import("../src/database/drizzle"); const loggerModule = await import("../src/logger"); - config = configModule.config; drizzle = drizzleModule; logger = loggerModule.createChildLogger("database.test"); - logger.info(`Testing with DATABASE_TYPE: ${config.DATABASE_TYPE}`); + logger.info("Testing PostgreSQL database initialization"); }); afterAll(async () => { diff --git a/tests/database/migrate.test.ts b/tests/database/migrate.test.ts index 53072cd..ac87144 100644 --- a/tests/database/migrate.test.ts +++ b/tests/database/migrate.test.ts @@ -1,21 +1,8 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { initializeMigrationSqliteDatabase } from "../../src/database/migrate"; +import { runMigrations } from "../../src/database/migrate"; -describe("initializeMigrationSqliteDatabase", () => { - it("creates a SQLite DB with WAL journal mode", () => { - const dir = mkdtempSync(join(tmpdir(), "bete-migrate-")); - const dbPath = join(dir, "test.db"); - const { sqlite, db } = initializeMigrationSqliteDatabase(dbPath); - - try { - expect(db).toBeDefined(); - expect(sqlite.pragma("journal_mode", { simple: true })).toBe("wal"); - } finally { - sqlite.close(); - rmSync(dir, { recursive: true, force: true }); - } +describe("runMigrations", () => { + it("exports the PostgreSQL migration runner", () => { + expect(runMigrations).toBeTypeOf("function"); }); }); diff --git a/tests/moderation/messageCapture.test.ts b/tests/moderation/messageCapture.test.ts index ee5661e..3e40bdd 100644 --- a/tests/moderation/messageCapture.test.ts +++ b/tests/moderation/messageCapture.test.ts @@ -23,7 +23,7 @@ type ModerationTestGlobal = typeof globalThis & { }; interface TestDatabase { - run(sql: string): void; + run(sql: string): Promise; } function getTestDatabase(): TestDatabase { @@ -63,8 +63,9 @@ function createMessage(id = "message-1"): TestMessage { async function createTables() { const db = getTestDatabase(); - db.run(`DROP TABLE IF EXISTS "messages"`); - db.run(` + await db.run(`DROP TABLE IF EXISTS "attachments" CASCADE`); + await db.run(`DROP TABLE IF EXISTS "messages" CASCADE`); + await db.run(` CREATE TABLE IF NOT EXISTS "messages" ( "id" text PRIMARY KEY NOT NULL, "guild_id" text NOT NULL, @@ -75,9 +76,9 @@ async function createTables() { "avatar_url" text, "content" text NOT NULL, "edited_content" text, - "created_at" integer NOT NULL, - "edited_at" integer, - "deleted_at" integer, + "created_at" bigint NOT NULL, + "edited_at" bigint, + "deleted_at" bigint, "type" text DEFAULT 'text' NOT NULL, "metadata" text, "ai_status" text DEFAULT 'pending' NOT NULL, @@ -89,11 +90,11 @@ async function createTables() { "ai_severity" text, "ai_confidence" real, "ai_recommended_action" text, - "ai_analyzed_at" integer, + "ai_analyzed_at" bigint, "ai_error" text ) `); - db.run(` + await db.run(` DROP TABLE IF EXISTS "attachments"; CREATE TABLE IF NOT EXISTS "attachments" ( "id" text PRIMARY KEY NOT NULL, @@ -109,8 +110,8 @@ async function createTables() { "uploaded_url" text, "upload_status" text DEFAULT 'pending' NOT NULL, "upload_error" text, - "created_at" integer NOT NULL, - "uploaded_at" integer + "created_at" bigint NOT NULL, + "uploaded_at" bigint ) `); } @@ -124,8 +125,8 @@ describe("captureMessage", () => { beforeEach(async () => { queueMessageAnalysis.mockClear(); const db = getTestDatabase(); - db.run(`DELETE FROM "attachments"`); - db.run(`DELETE FROM "messages"`); + await db.run(`DELETE FROM "attachments"`); + await db.run(`DELETE FROM "messages"`); delete (globalThis as ModerationTestGlobal).moderationBroadcaster; }); diff --git a/tests/moderation/messageStoreQueries.test.ts b/tests/moderation/messageStoreQueries.test.ts index db978d2..cf3c155 100644 --- a/tests/moderation/messageStoreQueries.test.ts +++ b/tests/moderation/messageStoreQueries.test.ts @@ -19,7 +19,7 @@ import { import type { MessageRecord } from "../../src/moderation/types"; interface TestDatabase { - run(sql: string): void; + run(sql: string): Promise; } function getTestDatabase(): TestDatabase { @@ -45,12 +45,13 @@ describe("message cursor helpers", () => { describe("message query integration tests", () => { beforeAll(async () => { await initializeDatabase(); - // Create tables using Drizzle schema (SQLite doesn't support migrations with PostgreSQL syntax) + // Create tables directly for isolated query integration tests const db = getTestDatabase(); try { // Create messages table await db.run(` - DROP TABLE IF EXISTS "messages"; + DROP TABLE IF EXISTS "attachments" CASCADE; + DROP TABLE IF EXISTS "messages" CASCADE; CREATE TABLE IF NOT EXISTS "messages" ( "id" text PRIMARY KEY NOT NULL, "guild_id" text NOT NULL, @@ -61,9 +62,9 @@ describe("message query integration tests", () => { "avatar_url" text, "content" text NOT NULL, "edited_content" text, - "created_at" integer NOT NULL, - "edited_at" integer, - "deleted_at" integer, + "created_at" bigint NOT NULL, + "edited_at" bigint, + "deleted_at" bigint, "type" text DEFAULT 'text' NOT NULL, "metadata" text, "ai_status" text DEFAULT 'pending' NOT NULL, @@ -75,7 +76,7 @@ describe("message query integration tests", () => { "ai_confidence" real, "ai_recommended_action" text, "ai_analysis" text, - "ai_analyzed_at" integer, + "ai_analyzed_at" bigint, "ai_error" text ) `); @@ -97,8 +98,8 @@ describe("message query integration tests", () => { "uploaded_url" text, "upload_status" text DEFAULT 'pending' NOT NULL, "upload_error" text, - "created_at" integer NOT NULL, - "uploaded_at" integer + "created_at" bigint NOT NULL, + "uploaded_at" bigint ) `); } catch (error) { @@ -110,8 +111,8 @@ describe("message query integration tests", () => { // Clear tables before each test try { const db = getTestDatabase(); - await db.run(`DELETE FROM "messages"`); await db.run(`DELETE FROM "attachments"`); + await db.run(`DELETE FROM "messages"`); } catch (error) { logger.debug({ error }, "Could not clear tables"); } @@ -614,6 +615,41 @@ describe("message query integration tests", () => { const msgId1 = "msg-att-1"; const msgId2 = "msg-att-2"; + await insertMessage({ + id: msgId1, + guild_id: "guild-123", + channel_id: "channel-456", + thread_id: null, + user_id: "user-789", + username: "testuser", + avatar_url: null, + content: "message with attachment 1", + edited_content: null, + created_at: Date.now(), + edited_at: null, + deleted_at: null, + type: "text", + metadata: null, + ai_status: "pending", + }); + await insertMessage({ + id: msgId2, + guild_id: "guild-123", + channel_id: "channel-456", + thread_id: null, + user_id: "user-789", + username: "testuser", + avatar_url: null, + content: "message with attachment 2", + edited_content: null, + created_at: Date.now(), + edited_at: null, + deleted_at: null, + type: "text", + metadata: null, + ai_status: "pending", + }); + const attachment1 = { id: "att-1", message_id: msgId1,