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)
|
# Optional: comma-separated category filter (empty = all categories)
|
||||||
# AUTO_DELETE_ALLOWED_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_*`.
|
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
|
```bash
|
||||||
# Generate migration Drizzle
|
# Generate migration Drizzle
|
||||||
pnpm run db:generate
|
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,
|
"when": 1780218363790,
|
||||||
"tag": "0005_optimize-message-index",
|
"tag": "0005_optimize-message-index",
|
||||||
"breakpoints": true
|
"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,
|
text TEXT PRIMARY KEY,
|
||||||
flags TEXT NOT NULL DEFAULT '[]',
|
flags TEXT NOT NULL DEFAULT '[]',
|
||||||
source TEXT NOT NULL DEFAULT 'local'
|
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,
|
analyzed_at BIGINT NOT NULL,
|
||||||
expires_at BIGINT NOT NULL,
|
expires_at BIGINT NOT NULL,
|
||||||
hit_count INTEGER NOT NULL DEFAULT 0
|
hit_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Client } from "discord.js-selfbot-v13";
|
import { Client } from "discord.js-selfbot-v13";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { closeDatabase, initializeDatabase } from "../database/drizzle.js";
|
import { closeDatabase, initializeDatabase } from "../database/drizzle.js";
|
||||||
|
import { runMigrations } from "../database/migrate.js";
|
||||||
import { createDiscordClientOptions } from "../discordClientOptions.js";
|
import { createDiscordClientOptions } from "../discordClientOptions.js";
|
||||||
import { createChildLogger } from "../logger.js";
|
import { createChildLogger } from "../logger.js";
|
||||||
import { startPendingAIAnalysisWorker } from "../moderation/aiAnalyzer.js";
|
import { startPendingAIAnalysisWorker } from "../moderation/aiAnalyzer.js";
|
||||||
@@ -40,6 +41,13 @@ export async function initializeApp() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (config.AUTO_MIGRATE_ON_STARTUP) {
|
||||||
|
logger.info(
|
||||||
|
"AUTO_MIGRATE_ON_STARTUP enabled; running database migrations",
|
||||||
|
);
|
||||||
|
await runMigrations();
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("Initializing database");
|
logger.info("Initializing database");
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
logger.info("PostgreSQL database initialized");
|
logger.info("PostgreSQL database initialized");
|
||||||
|
|||||||
@@ -167,6 +167,11 @@ const configSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default(true),
|
.default(true),
|
||||||
|
AUTO_MIGRATE_ON_STARTUP: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.default(true),
|
||||||
DATABASE_URL: z.string().optional(),
|
DATABASE_URL: z.string().optional(),
|
||||||
POSTGRES_HOST: z.string().default("localhost"),
|
POSTGRES_HOST: z.string().default("localhost"),
|
||||||
POSTGRES_PORT: z.coerce.number().int().positive().default(5432),
|
POSTGRES_PORT: z.coerce.number().int().positive().default(5432),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
||||||
import { Pool } from "pg";
|
import { Pool } from "pg";
|
||||||
|
import type { PoolClient } from "pg";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { createChildLogger } from "../logger.js";
|
import { createChildLogger } from "../logger.js";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
@@ -93,6 +94,27 @@ export async function executeGet(sql: string, params?: unknown[]) {
|
|||||||
return result.rows[0] ?? null;
|
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.
|
* Close the PostgreSQL connection pool.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+30
-5
@@ -1,19 +1,44 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
||||||
import { migrate as migratePostgres } from "drizzle-orm/node-postgres/migrator";
|
import { migrate as migratePostgres } from "drizzle-orm/node-postgres/migrator";
|
||||||
|
import { config } from "../config.js";
|
||||||
import { createChildLogger } from "../logger.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 logger = createChildLogger("migrate");
|
||||||
|
const MIGRATION_LOCK_KEY_1 = 2026;
|
||||||
|
const MIGRATION_LOCK_KEY_2 = 531;
|
||||||
|
|
||||||
export async function runMigrations(): Promise<void> {
|
export async function runMigrations(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info("Starting PostgreSQL migrations");
|
logger.info("Starting PostgreSQL migrations");
|
||||||
const db = (await initializeDatabase()) as Parameters<
|
await initializeDatabase();
|
||||||
typeof migratePostgres
|
|
||||||
>[0];
|
|
||||||
|
|
||||||
try {
|
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 {
|
} finally {
|
||||||
await closeDatabase();
|
await closeDatabase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,9 +369,9 @@ export const pgTextAnalysisCacheTable = pgTable(
|
|||||||
text: pgText("text").primaryKey(),
|
text: pgText("text").primaryKey(),
|
||||||
/** JSON array of moderation flags detected for this text (e.g. ["vulgar_language","harassment"]). */
|
/** JSON array of moderation flags detected for this text (e.g. ["vulgar_language","harassment"]). */
|
||||||
flags: pgText("flags").notNull().default("[]"),
|
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", {
|
source: pgText("source", {
|
||||||
enum: ["local", "nvidia", "primary_ai", "groq"],
|
enum: ["local", "nvidia", "primary_ai", "groq", "vision_llm"],
|
||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("local"),
|
.default("local"),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const logger = createChildLogger("text-cache-store");
|
|||||||
export interface TextCacheEntry {
|
export interface TextCacheEntry {
|
||||||
text: string;
|
text: string;
|
||||||
flags: string[];
|
flags: string[];
|
||||||
source: "local" | "nvidia" | "primary_ai" | "groq";
|
source: "local" | "nvidia" | "primary_ai" | "groq" | "vision_llm";
|
||||||
analyzed_at: number;
|
analyzed_at: number;
|
||||||
expires_at: number;
|
expires_at: number;
|
||||||
hit_count: number;
|
hit_count: number;
|
||||||
@@ -53,7 +53,7 @@ export async function getCachedText(
|
|||||||
export async function upsertCachedText(
|
export async function upsertCachedText(
|
||||||
text: string,
|
text: string,
|
||||||
flags: string[],
|
flags: string[],
|
||||||
source: "local" | "nvidia" | "primary_ai" | "groq",
|
source: "local" | "nvidia" | "primary_ai" | "groq" | "vision_llm",
|
||||||
expiresAt: number,
|
expiresAt: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
+16
-1
@@ -39,9 +39,10 @@ describe("loadConfig", () => {
|
|||||||
expect(config.AI_ANALYSIS_DEBOUNCE_MS).toBe(500);
|
expect(config.AI_ANALYSIS_DEBOUNCE_MS).toBe(500);
|
||||||
expect(config.AI_ANALYSIS_RECOVERY_INTERVAL_MS).toBe(15000);
|
expect(config.AI_ANALYSIS_RECOVERY_INTERVAL_MS).toBe(15000);
|
||||||
expect(config.AI_ANALYSIS_ERROR_COOLDOWN_MS).toBe(30000);
|
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_MAX_CONTEXT_TOKENS).toBe(8000);
|
||||||
expect(config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT).toBe(20);
|
expect(config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT).toBe(20);
|
||||||
|
expect(config.AUTO_MIGRATE_ON_STARTUP).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("coerces AI analysis tuning values", async () => {
|
it("coerces AI analysis tuning values", async () => {
|
||||||
@@ -107,4 +108,18 @@ describe("loadConfig", () => {
|
|||||||
expect(config.TEXT_CHANNEL_ID).toBe("text-channel");
|
expect(config.TEXT_CHANNEL_ID).toBe("text-channel");
|
||||||
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("voice-guild");
|
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { runMigrations } from "../../src/database/migrate";
|
|
||||||
|
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", () => {
|
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");
|
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