feat(database): add automatic migration on startup and enhance text analysis cache source
This commit is contained in:
@@ -88,3 +88,7 @@ AUTO_DELETE_ALLOWED_SEVERITIES=critical,high,medium
|
||||
# Optional: comma-separated category filter (empty = all categories)
|
||||
# AUTO_DELETE_ALLOWED_CATEGORIES=
|
||||
|
||||
# Database Migration Configuration
|
||||
# Safe default: run migrations on startup before the app accepts traffic.
|
||||
AUTO_MIGRATE_ON_STARTUP=true
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ pnpm run install:yt-dlp
|
||||
|
||||
Default database adalah SQLite di `.muxer-queue.db`. PostgreSQL dapat dipakai dengan `DATABASE_TYPE=postgres` dan konfigurasi `DATABASE_URL` atau variabel `POSTGRES_*`.
|
||||
|
||||
Jika aplikasi dijalankan di PostgreSQL production, migrasi dapat dijalankan otomatis saat startup dengan `AUTO_MIGRATE_ON_STARTUP=true`. Jalur ini memakai advisory lock supaya hanya satu instance yang memigrasi schema pada satu waktu.
|
||||
|
||||
```bash
|
||||
# Generate migration Drizzle
|
||||
pnpm run db:generate
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "text_analysis_cache" DROP CONSTRAINT IF EXISTS "text_analysis_cache_source_check";--> statement-breakpoint
|
||||
ALTER TABLE "text_analysis_cache" ADD CONSTRAINT "text_analysis_cache_source_check" CHECK ("source" IN ('local', 'nvidia', 'primary_ai', 'groq', 'vision_llm'));--> statement-breakpoint
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1780218363790,
|
||||
"tag": "0005_optimize-message-index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1780218363791,
|
||||
"tag": "0006_fix_text_analysis_cache_source",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS text_analysis_cache (
|
||||
text TEXT PRIMARY KEY,
|
||||
flags TEXT NOT NULL DEFAULT '[]',
|
||||
source TEXT NOT NULL DEFAULT 'local'
|
||||
CHECK (source IN ('local', 'nvidia', 'primary_ai', 'groq')),
|
||||
CHECK (source IN ('local', 'nvidia', 'primary_ai', 'groq', 'vision_llm')),
|
||||
analyzed_at BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
hit_count INTEGER NOT NULL DEFAULT 0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Client } from "discord.js-selfbot-v13";
|
||||
import { config } from "../config.js";
|
||||
import { closeDatabase, initializeDatabase } from "../database/drizzle.js";
|
||||
import { runMigrations } from "../database/migrate.js";
|
||||
import { createDiscordClientOptions } from "../discordClientOptions.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { startPendingAIAnalysisWorker } from "../moderation/aiAnalyzer.js";
|
||||
@@ -40,6 +41,13 @@ export async function initializeApp() {
|
||||
});
|
||||
|
||||
try {
|
||||
if (config.AUTO_MIGRATE_ON_STARTUP) {
|
||||
logger.info(
|
||||
"AUTO_MIGRATE_ON_STARTUP enabled; running database migrations",
|
||||
);
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
logger.info("Initializing database");
|
||||
await initializeDatabase();
|
||||
logger.info("PostgreSQL database initialized");
|
||||
|
||||
@@ -167,6 +167,11 @@ const configSchema = z
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
AUTO_MIGRATE_ON_STARTUP: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
DATABASE_URL: z.string().optional(),
|
||||
POSTGRES_HOST: z.string().default("localhost"),
|
||||
POSTGRES_PORT: z.coerce.number().int().positive().default(5432),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import type { PoolClient } from "pg";
|
||||
import { config } from "../config.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import * as schema from "./schema.js";
|
||||
@@ -93,6 +94,27 @@ export async function executeGet(sql: string, params?: unknown[]) {
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a function with a dedicated PostgreSQL client from the shared pool.
|
||||
* Use this for session-scoped operations such as advisory locks.
|
||||
*/
|
||||
export async function withDatabaseClient<T>(
|
||||
callback: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!rawPool) {
|
||||
throw new Error(
|
||||
"Database not initialized. Call initializeDatabase() first.",
|
||||
);
|
||||
}
|
||||
|
||||
const client = await rawPool.connect();
|
||||
try {
|
||||
return await callback(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the PostgreSQL connection pool.
|
||||
*/
|
||||
|
||||
+30
-5
@@ -1,19 +1,44 @@
|
||||
import "dotenv/config";
|
||||
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
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";
|
||||
import * as schema from "./schema.js";
|
||||
import {
|
||||
closeDatabase,
|
||||
initializeDatabase,
|
||||
withDatabaseClient,
|
||||
} from "./drizzle.js";
|
||||
|
||||
const logger = createChildLogger("migrate");
|
||||
const MIGRATION_LOCK_KEY_1 = 2026;
|
||||
const MIGRATION_LOCK_KEY_2 = 531;
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
try {
|
||||
logger.info("Starting PostgreSQL migrations");
|
||||
const db = (await initializeDatabase()) as Parameters<
|
||||
typeof migratePostgres
|
||||
>[0];
|
||||
await initializeDatabase();
|
||||
|
||||
try {
|
||||
await migratePostgres(db, { migrationsFolder: "./drizzle/migrations" });
|
||||
await withDatabaseClient(async (client) => {
|
||||
const db = drizzlePostgres(client, { schema });
|
||||
|
||||
await client.query("SELECT pg_advisory_lock($1, $2)", [
|
||||
MIGRATION_LOCK_KEY_1,
|
||||
MIGRATION_LOCK_KEY_2,
|
||||
]);
|
||||
|
||||
try {
|
||||
await migratePostgres(db, {
|
||||
migrationsFolder: "./drizzle/migrations",
|
||||
});
|
||||
} finally {
|
||||
await client.query("SELECT pg_advisory_unlock($1, $2)", [
|
||||
MIGRATION_LOCK_KEY_1,
|
||||
MIGRATION_LOCK_KEY_2,
|
||||
]);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await closeDatabase();
|
||||
}
|
||||
|
||||
@@ -369,9 +369,9 @@ export const pgTextAnalysisCacheTable = pgTable(
|
||||
text: pgText("text").primaryKey(),
|
||||
/** JSON array of moderation flags detected for this text (e.g. ["vulgar_language","harassment"]). */
|
||||
flags: pgText("flags").notNull().default("[]"),
|
||||
/** Which source produced this result: "local" | "nvidia" | "primary_ai" | "groq". */
|
||||
/** Which source produced this result: "local" | "nvidia" | "primary_ai" | "groq" | "vision_llm". */
|
||||
source: pgText("source", {
|
||||
enum: ["local", "nvidia", "primary_ai", "groq"],
|
||||
enum: ["local", "nvidia", "primary_ai", "groq", "vision_llm"],
|
||||
})
|
||||
.notNull()
|
||||
.default("local"),
|
||||
|
||||
@@ -7,7 +7,7 @@ const logger = createChildLogger("text-cache-store");
|
||||
export interface TextCacheEntry {
|
||||
text: string;
|
||||
flags: string[];
|
||||
source: "local" | "nvidia" | "primary_ai" | "groq";
|
||||
source: "local" | "nvidia" | "primary_ai" | "groq" | "vision_llm";
|
||||
analyzed_at: number;
|
||||
expires_at: number;
|
||||
hit_count: number;
|
||||
@@ -53,7 +53,7 @@ export async function getCachedText(
|
||||
export async function upsertCachedText(
|
||||
text: string,
|
||||
flags: string[],
|
||||
source: "local" | "nvidia" | "primary_ai" | "groq",
|
||||
source: "local" | "nvidia" | "primary_ai" | "groq" | "vision_llm",
|
||||
expiresAt: number,
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
+16
-1
@@ -39,9 +39,10 @@ describe("loadConfig", () => {
|
||||
expect(config.AI_ANALYSIS_DEBOUNCE_MS).toBe(500);
|
||||
expect(config.AI_ANALYSIS_RECOVERY_INTERVAL_MS).toBe(15000);
|
||||
expect(config.AI_ANALYSIS_ERROR_COOLDOWN_MS).toBe(30000);
|
||||
expect(config.AI_ANALYSIS_MAX_BATCH_SIZE).toBe(25);
|
||||
expect(config.AI_ANALYSIS_MAX_BATCH_SIZE).toBe(200);
|
||||
expect(config.AI_ANALYSIS_MAX_CONTEXT_TOKENS).toBe(8000);
|
||||
expect(config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT).toBe(20);
|
||||
expect(config.AUTO_MIGRATE_ON_STARTUP).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces AI analysis tuning values", async () => {
|
||||
@@ -107,4 +108,18 @@ describe("loadConfig", () => {
|
||||
expect(config.TEXT_CHANNEL_ID).toBe("text-channel");
|
||||
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("voice-guild");
|
||||
});
|
||||
|
||||
it("allows disabling startup migrations explicitly", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DISCORD_TOKEN: "token",
|
||||
AUTO_MIGRATE_ON_STARTUP: "false",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { loadConfig } = await import("../src/config");
|
||||
const config = loadConfig(process.env);
|
||||
|
||||
expect(config.AUTO_MIGRATE_ON_STARTUP).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runMigrations } from "../../src/database/migrate";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const migrateSpy = vi.fn();
|
||||
const initializeDatabaseSpy = vi.fn();
|
||||
const closeDatabaseSpy = vi.fn();
|
||||
const withDatabaseClientSpy = vi.fn();
|
||||
const lockQuerySpy = vi.fn();
|
||||
const unlockQuerySpy = vi.fn();
|
||||
const drizzleSpy = vi.fn();
|
||||
|
||||
vi.mock("drizzle-orm/node-postgres", () => ({
|
||||
drizzle: drizzleSpy,
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm/node-postgres/migrator", () => ({
|
||||
migrate: migrateSpy,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/config", () => ({
|
||||
config: {
|
||||
AUTO_MIGRATE_ON_STARTUP: true,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/database/drizzle", () => ({
|
||||
initializeDatabase: initializeDatabaseSpy,
|
||||
closeDatabase: closeDatabaseSpy,
|
||||
withDatabaseClient: withDatabaseClientSpy,
|
||||
}));
|
||||
|
||||
describe("runMigrations", () => {
|
||||
it("exports the PostgreSQL migration runner", () => {
|
||||
beforeEach(() => {
|
||||
migrateSpy.mockResolvedValue(undefined);
|
||||
initializeDatabaseSpy.mockResolvedValue({} as never);
|
||||
closeDatabaseSpy.mockResolvedValue(undefined);
|
||||
drizzleSpy.mockReturnValue({} as never);
|
||||
withDatabaseClientSpy.mockImplementation(
|
||||
async (callback: () => Promise<unknown>) =>
|
||||
callback({
|
||||
query: vi.fn().mockImplementation((sql: string) => {
|
||||
if (sql.includes("pg_advisory_lock")) {
|
||||
return lockQuerySpy(sql);
|
||||
}
|
||||
if (sql.includes("pg_advisory_unlock")) {
|
||||
return unlockQuerySpy(sql);
|
||||
}
|
||||
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
} as never),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("exports the PostgreSQL migration runner", async () => {
|
||||
const { runMigrations } = await import("../../src/database/migrate");
|
||||
|
||||
expect(runMigrations).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("uses a session lock around the migration batch", async () => {
|
||||
const { runMigrations } = await import("../../src/database/migrate");
|
||||
await runMigrations();
|
||||
|
||||
expect(initializeDatabaseSpy).toHaveBeenCalledTimes(1);
|
||||
expect(withDatabaseClientSpy).toHaveBeenCalledTimes(1);
|
||||
expect(migrateSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ migrationsFolder: "./drizzle/migrations" }),
|
||||
);
|
||||
expect(lockQuerySpy).toHaveBeenCalledWith(
|
||||
"SELECT pg_advisory_lock($1, $2)",
|
||||
);
|
||||
expect(unlockQuerySpy).toHaveBeenCalledWith(
|
||||
"SELECT pg_advisory_unlock($1, $2)",
|
||||
);
|
||||
expect(closeDatabaseSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user