diff --git a/Dockerfile b/Dockerfile index 95f05b20..53d24a23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,10 +20,11 @@ RUN addgroup --system --gid 1001 botgroup && \ # Copy production dependencies COPY --from=deps --chown=botuser:botgroup /app/node_modules ./node_modules -# Copy application source and config +# Copy application source, config, and migrations COPY --chown=botuser:botgroup package.json ./ COPY --chown=botuser:botgroup config.json ./ COPY --chown=botuser:botgroup src/ ./src/ +COPY --chown=botuser:botgroup migrations/ ./migrations/ # Create data directory for state persistence RUN mkdir -p data && chown botuser:botgroup data diff --git a/migrations/001_initial-schema.cjs b/migrations/001_initial-schema.cjs new file mode 100644 index 00000000..3fa8898c --- /dev/null +++ b/migrations/001_initial-schema.cjs @@ -0,0 +1,143 @@ +/** + * Initial schema migration. + * + * Captures ALL existing tables and indexes from the codebase so that + * node-pg-migrate becomes the single source of truth for DDL. + * + * Uses IF NOT EXISTS throughout so this migration is idempotent and + * safe to run against databases that already have these tables. + */ + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + // 1. config + pgm.sql(` + CREATE TABLE IF NOT EXISTS config ( + guild_id TEXT NOT NULL DEFAULT 'global', + key TEXT NOT NULL, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (guild_id, key) + ) + `); + + // 2. conversations + pgm.sql(` + CREATE TABLE IF NOT EXISTS conversations ( + id SERIAL PRIMARY KEY, + channel_id TEXT NOT NULL, + guild_id TEXT, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')), + content TEXT NOT NULL, + username TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + pgm.sql('CREATE INDEX IF NOT EXISTS idx_conversations_guild_id ON conversations (guild_id)'); + pgm.sql('CREATE INDEX IF NOT EXISTS idx_conversations_channel_created ON conversations (channel_id, created_at)'); + pgm.sql('CREATE INDEX IF NOT EXISTS idx_conversations_created_at ON conversations (created_at)'); + + // 3. mod_cases + pgm.sql(` + CREATE TABLE IF NOT EXISTS mod_cases ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + case_number INTEGER NOT NULL, + action TEXT NOT NULL, + target_id TEXT NOT NULL, + target_tag TEXT NOT NULL, + moderator_id TEXT NOT NULL, + moderator_tag TEXT NOT NULL, + reason TEXT, + duration TEXT, + expires_at TIMESTAMPTZ, + log_message_id TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(guild_id, case_number) + ) + `); + pgm.sql('CREATE INDEX IF NOT EXISTS idx_mod_cases_guild_target ON mod_cases (guild_id, target_id, created_at)'); + + // 4. mod_scheduled_actions (FK → mod_cases) + pgm.sql(` + CREATE TABLE IF NOT EXISTS mod_scheduled_actions ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + action TEXT NOT NULL, + target_id TEXT NOT NULL, + case_id INTEGER REFERENCES mod_cases(id) ON DELETE SET NULL, + execute_at TIMESTAMPTZ NOT NULL, + executed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + pgm.sql('CREATE INDEX IF NOT EXISTS idx_mod_scheduled_actions_pending ON mod_scheduled_actions (executed, execute_at)'); + + // 5. memory_optouts + pgm.sql(` + CREATE TABLE IF NOT EXISTS memory_optouts ( + user_id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + + // 6. ai_usage + pgm.sql(` + CREATE TABLE IF NOT EXISTS ai_usage ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('classify', 'respond')), + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cost_usd NUMERIC(10, 6) NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + user_id TEXT DEFAULT NULL, + search_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + pgm.sql('CREATE INDEX IF NOT EXISTS idx_ai_usage_guild_created ON ai_usage (guild_id, created_at)'); + pgm.sql('CREATE INDEX IF NOT EXISTS idx_ai_usage_created_at ON ai_usage (created_at)'); + pgm.sql('CREATE INDEX IF NOT EXISTS idx_ai_usage_user_created ON ai_usage (user_id, created_at) WHERE user_id IS NOT NULL'); + + // 7. logs + pgm.sql(` + CREATE TABLE IF NOT EXISTS logs ( + id SERIAL PRIMARY KEY, + level VARCHAR(10) NOT NULL, + message TEXT NOT NULL, + metadata JSONB DEFAULT '{}', + timestamp TIMESTAMPTZ DEFAULT NOW() + ) + `); + pgm.sql('CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs (timestamp)'); + pgm.sql('CREATE INDEX IF NOT EXISTS idx_logs_level ON logs (level)'); + + // 8. bot_restarts + pgm.sql(` + CREATE TABLE IF NOT EXISTS bot_restarts ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMPTZ DEFAULT NOW(), + reason TEXT NOT NULL DEFAULT 'startup', + version TEXT, + uptime_seconds NUMERIC + ) + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + // Drop in reverse FK order + pgm.sql('DROP TABLE IF EXISTS bot_restarts CASCADE'); + pgm.sql('DROP TABLE IF EXISTS logs CASCADE'); + pgm.sql('DROP TABLE IF EXISTS ai_usage CASCADE'); + pgm.sql('DROP TABLE IF EXISTS memory_optouts CASCADE'); + pgm.sql('DROP TABLE IF EXISTS mod_scheduled_actions CASCADE'); + pgm.sql('DROP TABLE IF EXISTS mod_cases CASCADE'); + pgm.sql('DROP TABLE IF EXISTS conversations CASCADE'); + pgm.sql('DROP TABLE IF EXISTS config CASCADE'); +}; diff --git a/package.json b/package.json index 8f01aeb1..b17ccc01 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "lint": "biome check .", "lint:fix": "biome check . --write", "format": "biome format . --write", + "migrate": "node-pg-migrate up --migrations-dir migrations", + "migrate:down": "node-pg-migrate down --migrations-dir migrations", + "migrate:create": "node-pg-migrate create --migrations-dir migrations", "prepare": "git config core.hooksPath .hooks" }, "dependencies": { @@ -24,6 +27,7 @@ "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "mem0ai": "^2.2.2", + "node-pg-migrate": "^8.0.4", "pg": "^8.18.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", @@ -36,7 +40,7 @@ } }, "engines": { - "node": ">=18.0.0" + "node": ">=20.11.0" }, "devDependencies": { "@biomejs/biome": "^2.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 257402ce..9bc092d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: mem0ai: specifier: ^2.2.2 version: 2.2.2(@anthropic-ai/sdk@0.40.1(encoding@0.1.13))(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260214.0)(@google/genai@1.41.0)(@langchain/core@0.3.80(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.14.0)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.95.3)(@types/jest@29.5.14)(@types/pg@8.11.0)(@types/sqlite3@3.1.11)(cloudflare@4.5.0(encoding@0.1.13))(encoding@0.1.13)(groq-sdk@0.3.0(encoding@0.1.13))(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.18.0)(redis@4.7.1)(sqlite3@5.1.7)(ws@8.19.0) + node-pg-migrate: + specifier: ^8.0.4 + version: 8.0.4(@types/pg@8.11.0)(pg@8.18.0) pg: specifier: ^8.18.0 version: 8.18.0 @@ -60,9 +63,6 @@ importers: web: dependencies: - '@radix-ui/react-accordion': - specifier: ^1.2.12 - version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-avatar': specifier: ^1.1.11 version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -72,18 +72,12 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-label': - specifier: ^2.1.8 - version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-separator': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-switch': - specifier: ^1.2.6 - version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -120,9 +114,6 @@ importers: tailwind-merge: specifier: ^3.4.1 version: 3.4.1 - zustand: - specifier: ^5.0.11 - version: 5.0.11(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@tailwindcss/postcss': specifier: ^4.1.18 @@ -806,6 +797,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@jest/expect-utils@29.7.0': resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -961,19 +956,6 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - '@radix-ui/react-accordion@1.2.12': - resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -1000,19 +982,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collapsible@1.1.12': - resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -1132,19 +1101,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-label@2.1.8': - resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-menu@2.1.16': resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} peerDependencies: @@ -1267,19 +1223,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-switch@1.2.6': - resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -1334,15 +1277,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-rect@1.1.1': resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -2094,6 +2028,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + cloudflare@4.5.0: resolution: {integrity: sha512-fPcbPKx4zF45jBvQ0z7PCdgejVAPBBCZxwqk1k7krQNfpM07Cfj97/Q6wBzvYqlWXx/zt1S9+m8vnfCe06umbQ==} @@ -2577,6 +2515,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2597,6 +2539,12 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -2789,6 +2737,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + jest-diff@29.7.0: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2988,6 +2940,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3097,6 +3053,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.3: resolution: {integrity: sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==} @@ -3251,6 +3211,17 @@ packages: engines: {node: '>= 10.12.0'} hasBin: true + node-pg-migrate@8.0.4: + resolution: {integrity: sha512-HTlJ6fOT/2xHhAUtsqSN85PGMAqSbfGJNRwQF8+ZwQ1+sVGNUTl/ZGEshPsOI3yV22tPIyHXrKXr3S0JxeYLrg==} + engines: {node: '>=20.11.0'} + hasBin: true + peerDependencies: + '@types/pg': '>=6.0.0 <9.0.0' + pg: '>=4.3.0 <9.0.0' + peerDependenciesMeta: + '@types/pg': + optional: true + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -3370,6 +3341,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -3631,6 +3606,10 @@ packages: redux@5.0.1: resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -4216,12 +4195,24 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -4233,24 +4224,6 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zustand@5.0.11: - resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=18.0.0' - immer: '>=9.0.6' - react: '>=18.0.0' - use-sync-external-store: '>=1.2.0' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - use-sync-external-store: - optional: true - snapshots: '@adobe/css-tools@4.4.4': {} @@ -4849,6 +4822,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': {} + '@jest/expect-utils@29.7.0': dependencies: jest-get-type: 29.6.3 @@ -4862,7 +4837,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.3.0 + '@types/node': 22.19.11 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -4997,23 +4972,6 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -5036,22 +4994,6 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -5162,15 +5104,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5293,21 +5226,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -5349,12 +5267,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/rect': 1.1.1 @@ -5747,10 +5659,11 @@ snapshots: '@types/node@25.3.0': dependencies: undici-types: 7.18.2 + optional: true '@types/pg@8.11.0': dependencies: - '@types/node': 25.3.0 + '@types/node': 22.19.11 pg-protocol: 1.11.0 pg-types: 4.1.0 @@ -5768,7 +5681,7 @@ snapshots: '@types/sqlite3@3.1.11': dependencies: - '@types/node': 25.3.0 + '@types/node': 22.19.11 '@types/stack-utils@2.0.3': {} @@ -6108,6 +6021,12 @@ snapshots: client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cloudflare@4.5.0(encoding@0.1.13): dependencies: '@types/node': 18.19.130 @@ -6599,6 +6518,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6630,6 +6551,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.4 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -6835,6 +6765,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -6866,7 +6800,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.3.0 + '@types/node': 22.19.11 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -7036,6 +6970,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7156,6 +7092,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.3 + minimatch@3.1.3: dependencies: brace-expansion: 1.1.12 @@ -7318,6 +7258,14 @@ snapshots: - supports-color optional: true + node-pg-migrate@8.0.4(@types/pg@8.11.0)(pg@8.18.0): + dependencies: + glob: 11.1.0 + pg: 8.18.0 + yargs: 17.7.2 + optionalDependencies: + '@types/pg': 8.11.0 + node-releases@2.0.27: {} nopt@5.0.0: @@ -7437,6 +7385,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.6 + minipass: 7.1.3 + path-to-regexp@8.3.0: {} pathe@2.0.3: {} @@ -7585,7 +7538,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.3.0 + '@types/node': 22.19.11 long: 5.3.2 proxy-addr@2.0.7: @@ -7717,6 +7670,8 @@ snapshots: redux@5.0.1: {} + require-directory@2.1.1: {} + reselect@5.1.1: {} retry@0.12.0: @@ -8127,7 +8082,8 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.18.2: {} + undici-types@7.18.2: + optional: true undici@6.23.0: {} @@ -8397,10 +8353,24 @@ snapshots: xtend@4.0.2: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yallist@4.0.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 @@ -8412,10 +8382,3 @@ snapshots: zod@3.25.76: {} zod@4.3.6: {} - - zustand@5.0.11(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): - optionalDependencies: - '@types/react': 19.2.14 - immer: 11.1.4 - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/src/config-listeners.js b/src/config-listeners.js index d36e9290..ff9fe09e 100644 --- a/src/config-listeners.js +++ b/src/config-listeners.js @@ -10,7 +10,6 @@ import { addPostgresTransport, error, info, removePostgresTransport } from './logger.js'; import { onConfigChange } from './modules/config.js'; -import { initLogsTable } from './transports/postgres.js'; /** @type {import('winston').transport | null} */ let pgTransport = null; @@ -39,7 +38,6 @@ export function registerConfigListeners({ dbPool, config }) { const enabled = dbConfig?.enabled; if (enabled && !pgTransport) { - await initLogsTable(dbPool); pgTransport = addPostgresTransport(dbPool, dbConfig); info('PostgreSQL logging transport enabled via config change', { path: changePath }); } else if (enabled && pgTransport) { diff --git a/src/db.js b/src/db.js index e7b10e86..3abd0708 100644 --- a/src/db.js +++ b/src/db.js @@ -1,11 +1,13 @@ /** * Database Module - * PostgreSQL connection pool and schema initialization + * PostgreSQL connection pool and migration runner */ +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; import pg from 'pg'; -import { info, error as logError, warn } from './logger.js'; -import { initLogsTable } from './transports/postgres.js'; +import { runner } from 'node-pg-migrate'; +import { info, error as logError } from './logger.js'; const { Pool } = pg; @@ -16,17 +18,17 @@ let pool = null; let initializing = false; /** - * Determine SSL configuration based on DATABASE_SSL env var and connection string. + * Selects the SSL configuration for a pg.Pool based on DATABASE_SSL and the connection string. * * DATABASE_SSL values: * "false" / "off" → SSL disabled - * "no-verify" → SSL enabled but server cert not verified - * "true" / "on" / unset → SSL enabled with full verification + * "no-verify" → SSL enabled but server certificate not verified + * "true" / "on" / unset → SSL enabled with server certificate verification * - * Railway internal connections always disable SSL regardless of env var. + * Connections whose host contains "railway.internal" always disable SSL. * * @param {string} connectionString - Database connection URL - * @returns {false|{rejectUnauthorized: boolean}} SSL config for pg.Pool + * @returns {false|{rejectUnauthorized: boolean}} `false` to disable SSL, or an object with `rejectUnauthorized` indicating whether server certificates must be verified */ function getSslConfig(connectionString) { // Railway internal connections never need SSL @@ -49,8 +51,34 @@ function getSslConfig(connectionString) { } /** - * Initialize the database connection pool and create schema - * @returns {Promise} The connection pool + * Apply pending PostgreSQL schema migrations from the project's migrations directory. + * + * @param {string} databaseUrl - Connection string used to run migrations against the database. + * @returns {Promise} + */ +async function runMigrations(databaseUrl) { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const migrationsDir = path.resolve(__dirname, '..', 'migrations'); + + await runner({ + databaseUrl, + dir: migrationsDir, + direction: 'up', + migrationsTable: 'pgmigrations', + log: (msg) => info(msg), + }); + + info('Database migrations applied'); +} + +/** + * Initialize the PostgreSQL connection pool and apply any pending database migrations. + * + * @returns {Promise} The initialized pg.Pool instance. + * @throws {Error} If initialization is already in progress. + * @throws {Error} If the DATABASE_URL environment variable is not set. + * @throws {Error} If the connection test or migration application fails. */ export async function initDb() { if (initializing) { @@ -89,184 +117,8 @@ export async function initDb() { client.release(); } - // Create schema - await pool.query(` - CREATE TABLE IF NOT EXISTS config ( - guild_id TEXT NOT NULL DEFAULT 'global', - key TEXT NOT NULL, - value JSONB NOT NULL, - updated_at TIMESTAMPTZ DEFAULT NOW(), - PRIMARY KEY (guild_id, key) - ) - `); - - // Migrate existing config table: add guild_id column and composite PK. - // Looks up the actual PK constraint name from pg_constraint instead of - // assuming 'config_pkey', which may differ across environments. - await pool.query(` - DO $$ - DECLARE - pk_name TEXT; - BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'config' AND column_name = 'guild_id' - ) THEN - ALTER TABLE config ADD COLUMN guild_id TEXT NOT NULL DEFAULT 'global'; - SELECT conname INTO pk_name FROM pg_constraint - WHERE conrelid = 'config'::regclass AND contype = 'p'; - IF pk_name IS NOT NULL THEN - EXECUTE format('ALTER TABLE config DROP CONSTRAINT %I', pk_name); - END IF; - ALTER TABLE config ADD PRIMARY KEY (guild_id, key); - END IF; - END $$ - `); - - // Note: No standalone guild_id index needed — the composite PK (guild_id, key) - // already covers guild_id-only queries via leftmost prefix. - - await pool.query(` - CREATE TABLE IF NOT EXISTS conversations ( - id SERIAL PRIMARY KEY, - channel_id TEXT NOT NULL, - guild_id TEXT, - role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')), - content TEXT NOT NULL, - username TEXT, - created_at TIMESTAMPTZ DEFAULT NOW() - ) - `); - - // Backfill guild_id for databases created before this column existed. - // ADD COLUMN IF NOT EXISTS requires PostgreSQL 9.6+. - try { - await pool.query(` - ALTER TABLE conversations ADD COLUMN IF NOT EXISTS guild_id TEXT - `); - } catch (err) { - warn('Failed to add guild_id column (requires PG 9.6+)', { error: err.message }); - } - - // Create index - wrap in try/catch in case ALTER TABLE failed (e.g., PG < 9.6) - try { - await pool.query(` - CREATE INDEX IF NOT EXISTS idx_conversations_guild_id - ON conversations (guild_id) - `); - } catch (err) { - warn('Failed to create guild_id index (column may not exist)', { error: err.message }); - } - - await pool.query(` - CREATE INDEX IF NOT EXISTS idx_conversations_channel_created - ON conversations (channel_id, created_at) - `); - - await pool.query(` - CREATE INDEX IF NOT EXISTS idx_conversations_created_at - ON conversations (created_at) - `); - - // Moderation tables - await pool.query(` - CREATE TABLE IF NOT EXISTS mod_cases ( - id SERIAL PRIMARY KEY, - guild_id TEXT NOT NULL, - case_number INTEGER NOT NULL, - action TEXT NOT NULL, - target_id TEXT NOT NULL, - target_tag TEXT NOT NULL, - moderator_id TEXT NOT NULL, - moderator_tag TEXT NOT NULL, - reason TEXT, - duration TEXT, - expires_at TIMESTAMPTZ, - log_message_id TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(guild_id, case_number) - ) - `); - - await pool.query(` - CREATE INDEX IF NOT EXISTS idx_mod_cases_guild_target - ON mod_cases (guild_id, target_id, created_at) - `); - await pool.query(` - CREATE TABLE IF NOT EXISTS mod_scheduled_actions ( - id SERIAL PRIMARY KEY, - guild_id TEXT NOT NULL, - action TEXT NOT NULL, - target_id TEXT NOT NULL, - case_id INTEGER REFERENCES mod_cases(id), - execute_at TIMESTAMPTZ NOT NULL, - executed BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW() - ) - `); - - await pool.query(` - CREATE INDEX IF NOT EXISTS idx_mod_scheduled_actions_pending - ON mod_scheduled_actions (executed, execute_at) - `); - - // Memory opt-out table - await pool.query(` - CREATE TABLE IF NOT EXISTS memory_optouts ( - user_id TEXT PRIMARY KEY, - created_at TIMESTAMPTZ DEFAULT NOW() - ) - `); - - // AI usage analytics table - await pool.query(` - CREATE TABLE IF NOT EXISTS ai_usage ( - id SERIAL PRIMARY KEY, - guild_id TEXT NOT NULL, - channel_id TEXT NOT NULL, - type TEXT NOT NULL CHECK (type IN ('classify', 'respond')), - model TEXT NOT NULL, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cache_creation_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cost_usd NUMERIC(10, 6) NOT NULL DEFAULT 0, - duration_ms INTEGER NOT NULL DEFAULT 0, - user_id TEXT DEFAULT NULL, - search_count INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW() - ) - `); - - // Idempotent migrations for existing databases (silently skip if columns exist) - for (const ddl of [ - 'ALTER TABLE ai_usage ADD COLUMN user_id TEXT DEFAULT NULL', - 'ALTER TABLE ai_usage ADD COLUMN search_count INTEGER NOT NULL DEFAULT 0', - ]) { - await pool.query(ddl).catch(() => {}); - } - - await pool.query(` - CREATE INDEX IF NOT EXISTS idx_ai_usage_guild_created - ON ai_usage (guild_id, created_at) - `); - - await pool.query(` - CREATE INDEX IF NOT EXISTS idx_ai_usage_created_at - ON ai_usage (created_at) - `); - - await pool.query(` - CREATE INDEX IF NOT EXISTS idx_ai_usage_user_created - ON ai_usage (user_id, created_at) - `); - - // Logs table for persistent logging transport - try { - await initLogsTable(pool); - } catch (err) { - logError('Failed to initialize logs table', { error: err.message }); - } + // Run pending migrations + await runMigrations(connectionString); info('Database schema initialized'); } catch (err) { diff --git a/src/index.js b/src/index.js index aca43c61..919f1ad4 100644 --- a/src/index.js +++ b/src/index.js @@ -38,7 +38,7 @@ import { checkMem0Health, markUnavailable } from './modules/memory.js'; import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; import { loadOptOuts } from './modules/optout.js'; import { startTriage, stopTriage } from './modules/triage.js'; -import { initLogsTable, pruneOldLogs } from './transports/postgres.js'; +import { pruneOldLogs } from './transports/postgres.js'; import { HealthMonitor } from './utils/health.js'; import { loadCommandsFromDirectory } from './utils/loadCommands.js'; import { getPermissionError, hasPermission } from './utils/permissions.js'; @@ -354,7 +354,6 @@ async function startup() { // Wire up PostgreSQL logging transport if enabled in config if (config.logging?.database?.enabled) { try { - await initLogsTable(dbPool); const transport = addPostgresTransport(dbPool, config.logging.database); setInitialTransport(transport); info('PostgreSQL logging transport enabled'); diff --git a/src/transports/postgres.js b/src/transports/postgres.js index bd6d6582..8ad16cb4 100644 --- a/src/transports/postgres.js +++ b/src/transports/postgres.js @@ -167,41 +167,6 @@ export class PostgresTransport extends Transport { } } -/** - * Initialize the logs table in PostgreSQL. - * Uses CREATE TABLE IF NOT EXISTS — safe for repeated calls. - * - * @param {import('pg').Pool} pool - PostgreSQL connection pool - * @returns {Promise} - */ -export async function initLogsTable(pool) { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - await client.query(` - CREATE TABLE IF NOT EXISTS logs ( - id SERIAL PRIMARY KEY, - level VARCHAR(10) NOT NULL, - message TEXT NOT NULL, - metadata JSONB DEFAULT '{}', - timestamp TIMESTAMPTZ DEFAULT NOW() - ) - `); - - await client.query('CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp)'); - - await client.query('CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level)'); - - await client.query('COMMIT'); - } catch (err) { - await client.query('ROLLBACK'); - throw err; - } finally { - client.release(); - } -} - /** * Delete log entries older than the specified retention period. * diff --git a/src/utils/restartTracker.js b/src/utils/restartTracker.js index 5801ab4d..5abf5554 100644 --- a/src/utils/restartTracker.js +++ b/src/utils/restartTracker.js @@ -14,38 +14,19 @@ let startedAt = null; let lastRestartId = null; /** - * Ensure the bot_restarts table exists. + * Record a restart event in the bot_restarts table and update in-memory restart state. * - * @param {import('pg').Pool} pool - PostgreSQL connection pool - * @returns {Promise} - */ -async function ensureTable(pool) { - await pool.query(` - CREATE TABLE IF NOT EXISTS bot_restarts ( - id SERIAL PRIMARY KEY, - timestamp TIMESTAMPTZ DEFAULT NOW(), - reason TEXT NOT NULL DEFAULT 'startup', - version TEXT, - uptime_seconds NUMERIC - ) - `); -} - -/** - * Record a bot restart in the database. - * Auto-creates the table if it does not exist. + * Sets the module's start timestamp and inserts a row with the provided reason and version. * - * @param {import('pg').Pool} pool - PostgreSQL connection pool - * @param {string} [reason='startup'] - Human-readable restart reason - * @param {string|null} [version=null] - Bot version string (e.g. from package.json) - * @returns {Promise} The new row ID, or null on failure + * @param {import('pg').Pool} pool - PostgreSQL connection pool. + * @param {string} [reason='startup'] - Human-readable restart reason. + * @param {string|null} [version=null] - Bot version string (e.g. from package.json). + * @returns {Promise} The new row ID if insertion succeeded, or `null` on failure. */ export async function recordRestart(pool, reason = 'startup', version = null) { startedAt = Date.now(); try { - await ensureTable(pool); - const result = await pool.query( `INSERT INTO bot_restarts (reason, version) VALUES ($1, $2) RETURNING id`, [reason, version ?? null], @@ -93,9 +74,11 @@ export async function updateUptimeOnShutdown(pool) { /** * Retrieve recent restart records, newest first. * - * @param {import('pg').Pool} pool - PostgreSQL connection pool - * @param {number} [limit=20] - Maximum number of rows to return - * @returns {Promise>} + * Each row contains the restart `id`, `timestamp`, `reason`, `version` (or `null`), and `uptime_seconds` (or `null`). + * + * @param {import('pg').Pool} pool - PostgreSQL connection pool. + * @param {number} [limit=20] - Maximum number of rows to return; values less than 1 are treated as 1. + * @returns {Promise>} Recent restart rows, or an empty array if the query fails. */ export async function getRestarts(pool, limit = 20) { try { @@ -108,23 +91,6 @@ export async function getRestarts(pool, limit = 20) { ); return result.rows; } catch (err) { - // Self-heal: auto-create table if it doesn't exist, then retry - if (err.code === '42P01') { - try { - await ensureTable(pool); - const result = await pool.query( - `SELECT id, timestamp, reason, version, uptime_seconds - FROM bot_restarts - ORDER BY timestamp DESC - LIMIT $1`, - [Math.max(1, Math.floor(limit))], - ); - return result.rows; - } catch (retryErr) { - logError('Failed to query restarts after table creation', { error: retryErr.message }); - return []; - } - } logError('Failed to query restarts', { error: err.message }); return []; } diff --git a/tests/config-listeners.test.js b/tests/config-listeners.test.js index 08c5c01a..7bd15852 100644 --- a/tests/config-listeners.test.js +++ b/tests/config-listeners.test.js @@ -15,13 +15,9 @@ vi.mock('../src/modules/config.js', () => ({ onConfigChange: vi.fn(), })); -vi.mock('../src/transports/postgres.js', () => ({ - initLogsTable: vi.fn().mockResolvedValue(undefined), -})); - describe('config-listeners', () => { let registerConfigListeners, removeLoggingTransport, setInitialTransport; - let onConfigChange, addPostgresTransport, removePostgresTransportMock, initLogsTable; + let onConfigChange, addPostgresTransport, removePostgresTransportMock; let loggerInfo, loggerError; beforeEach(async () => { @@ -37,10 +33,6 @@ describe('config-listeners', () => { vi.mock('../src/modules/config.js', () => ({ onConfigChange: vi.fn(), })); - vi.mock('../src/transports/postgres.js', () => ({ - initLogsTable: vi.fn().mockResolvedValue(undefined), - })); - // Import fresh copies of the mocked modules const loggerMod = await import('../src/logger.js'); addPostgresTransport = loggerMod.addPostgresTransport; @@ -51,9 +43,6 @@ describe('config-listeners', () => { const configMod = await import('../src/modules/config.js'); onConfigChange = configMod.onConfigChange; - const pgMod = await import('../src/transports/postgres.js'); - initLogsTable = pgMod.initLogsTable; - // Import the module under test with fresh internal state const mod = await import('../src/config-listeners.js'); registerConfigListeners = mod.registerConfigListeners; @@ -108,7 +97,7 @@ describe('config-listeners', () => { // ── Transport enabled ────────────────────────────────────────────────── describe('transport enable (enabled=true, no existing transport)', () => { - it('calls initLogsTable then addPostgresTransport', async () => { + it('calls addPostgresTransport when enabled', async () => { const dbPool = { query: vi.fn() }; const config = { logging: { database: { enabled: true, batchSize: 50 } } }; const listeners = registerAndCapture(dbPool, config); @@ -120,7 +109,6 @@ describe('config-listeners', () => { 'global', ); - expect(initLogsTable).toHaveBeenCalledWith(dbPool); expect(addPostgresTransport).toHaveBeenCalledWith(dbPool, config.logging.database); expect(loggerInfo).toHaveBeenCalledWith( 'PostgreSQL logging transport enabled via config change', @@ -283,8 +271,10 @@ describe('config-listeners', () => { const config = { logging: { database: { enabled: true } } }; const listeners = registerAndCapture(dbPool, config); - // Make initLogsTable throw - initLogsTable.mockRejectedValueOnce(new Error('DB connection failed')); + // Make addPostgresTransport throw + addPostgresTransport.mockImplementationOnce(() => { + throw new Error('DB connection failed'); + }); await listeners['logging.database.enabled']( true, @@ -315,7 +305,6 @@ describe('config-listeners', () => { 'guild-123', ); - expect(initLogsTable).not.toHaveBeenCalled(); expect(addPostgresTransport).not.toHaveBeenCalled(); }); @@ -331,7 +320,6 @@ describe('config-listeners', () => { 'global', ); - expect(initLogsTable).toHaveBeenCalled(); expect(addPostgresTransport).toHaveBeenCalled(); }); @@ -347,7 +335,6 @@ describe('config-listeners', () => { undefined, ); - expect(initLogsTable).toHaveBeenCalled(); expect(addPostgresTransport).toHaveBeenCalled(); }); }); @@ -366,7 +353,6 @@ describe('config-listeners', () => { 'global', ); - expect(initLogsTable).not.toHaveBeenCalled(); expect(addPostgresTransport).not.toHaveBeenCalled(); }); }); @@ -429,7 +415,6 @@ describe('config-listeners', () => { 'global', ); - expect(initLogsTable).not.toHaveBeenCalled(); expect(addPostgresTransport).not.toHaveBeenCalled(); expect(removePostgresTransportMock).not.toHaveBeenCalled(); }); diff --git a/tests/db.test.js b/tests/db.test.js index 000a1d5d..10f00d83 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { initLogsTable } from '../src/transports/postgres.js'; const pgMocks = vi.hoisted(() => ({ poolConfig: null, @@ -11,9 +10,13 @@ const pgMocks = vi.hoisted(() => ({ clientRelease: vi.fn(), })); -// Mock the postgres transport (imported by db.js for initLogsTable) -vi.mock('../src/transports/postgres.js', () => ({ - initLogsTable: vi.fn().mockResolvedValue(undefined), +const migrationMocks = vi.hoisted(() => ({ + runner: vi.fn().mockResolvedValue(undefined), +})); + +// Mock node-pg-migrate runner +vi.mock('node-pg-migrate', () => ({ + runner: migrationMocks.runner, })); vi.mock('pg', () => { @@ -61,6 +64,7 @@ describe('db module', () => { pgMocks.poolEnd.mockReset().mockResolvedValue(undefined); pgMocks.clientQuery.mockReset().mockResolvedValue({}); pgMocks.clientRelease.mockReset(); + migrationMocks.runner.mockReset().mockResolvedValue(undefined); pgMocks.poolConnect.mockResolvedValue({ query: pgMocks.clientQuery, @@ -96,7 +100,7 @@ describe('db module', () => { }); describe('initDb', () => { - it('should initialize database pool and create schema', async () => { + it('should initialize database pool and run migrations', async () => { const pool = await dbModule.initDb(); expect(pool).toBeDefined(); @@ -104,34 +108,14 @@ describe('db module', () => { expect(pgMocks.clientQuery).toHaveBeenCalledWith('SELECT NOW()'); expect(pgMocks.clientRelease).toHaveBeenCalled(); - // Should have created tables and indexes - const queries = pgMocks.poolQuery.mock.calls.map((c) => c[0]); - expect(queries.some((q) => q.includes('CREATE TABLE IF NOT EXISTS config'))).toBe(true); - expect(queries.some((q) => q.includes('CREATE TABLE IF NOT EXISTS conversations'))).toBe( - true, - ); - expect(queries.some((q) => q.includes('idx_conversations_channel_created'))).toBe(true); - expect(queries.some((q) => q.includes('idx_conversations_created_at'))).toBe(true); - expect(queries.some((q) => q.includes('idx_conversations_guild_id'))).toBe(true); - - // Moderation tables - expect(queries.some((q) => q.includes('CREATE TABLE IF NOT EXISTS mod_cases'))).toBe(true); - expect( - queries.some((q) => q.includes('CREATE TABLE IF NOT EXISTS mod_scheduled_actions')), - ).toBe(true); - expect(queries.some((q) => q.includes('idx_mod_cases_guild_target'))).toBe(true); - expect(queries.some((q) => q.includes('idx_mod_scheduled_actions_pending'))).toBe(true); - }); - - it('should initialize the logs table during schema setup', async () => { - await dbModule.initDb(); - expect(initLogsTable).toHaveBeenCalled(); - }); - - it('should not fail startup if logs table initialization fails', async () => { - initLogsTable.mockRejectedValueOnce(new Error('logs table failed')); - const pool = await dbModule.initDb(); - expect(pool).toBeDefined(); + // Should have called the migration runner + expect(migrationMocks.runner).toHaveBeenCalledTimes(1); + const runnerOpts = migrationMocks.runner.mock.calls[0][0]; + expect(runnerOpts.databaseUrl).toBe('postgresql://test:test@localhost:5432/testdb'); + expect(runnerOpts.direction).toBe('up'); + expect(runnerOpts.migrationsTable).toBe('pgmigrations'); + expect(runnerOpts.dir).toContain('migrations'); + expect(typeof runnerOpts.log).toBe('function'); }); it('should return existing pool on second call', async () => { @@ -140,6 +124,7 @@ describe('db module', () => { expect(pool1).toBe(pool2); expect(pgMocks.poolConnect).toHaveBeenCalledTimes(1); + expect(migrationMocks.runner).toHaveBeenCalledTimes(1); }); it('should reject concurrent initDb calls while initialization is in progress', async () => { @@ -176,6 +161,12 @@ describe('db module', () => { await expect(dbModule.initDb()).rejects.toThrow('connection failed'); expect(pgMocks.poolEnd).toHaveBeenCalled(); }); + + it('should clean up pool on migration failure', async () => { + migrationMocks.runner.mockRejectedValueOnce(new Error('migration failed')); + await expect(dbModule.initDb()).rejects.toThrow('migration failed'); + expect(pgMocks.poolEnd).toHaveBeenCalled(); + }); }); describe('getPool', () => { diff --git a/tests/index.test.js b/tests/index.test.js index 9bbb7b5e..6279cc58 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -50,7 +50,6 @@ const mocks = vi.hoisted(() => ({ }, postgres: { - initLogsTable: vi.fn(), pruneOldLogs: vi.fn(), }, @@ -170,7 +169,6 @@ vi.mock('../src/modules/config.js', () => ({ })); vi.mock('../src/transports/postgres.js', () => ({ - initLogsTable: mocks.postgres.initLogsTable, pruneOldLogs: mocks.postgres.pruneOldLogs, })); @@ -264,7 +262,6 @@ async function importIndex({ mocks.ai.stopConversationCleanup.mockReset(); mocks.config.onConfigChangeCallbacks = {}; - mocks.postgres.initLogsTable.mockReset().mockResolvedValue(undefined); mocks.postgres.pruneOldLogs.mockReset().mockResolvedValue(0); mocks.config.loadConfig.mockReset().mockImplementation(() => { @@ -746,7 +743,6 @@ describe('index.js', () => { loggingConfig.logging.database.enabled = true; await invokeConfigCallback('logging.database.enabled', true); - expect(mocks.postgres.initLogsTable).toHaveBeenCalled(); expect(mocks.logger.addPostgresTransport).toHaveBeenCalled(); expect(mocks.logger.info).toHaveBeenCalledWith( 'PostgreSQL logging transport enabled via config change', diff --git a/tests/transports/postgres.test.js b/tests/transports/postgres.test.js index 68677d96..35c53096 100644 --- a/tests/transports/postgres.test.js +++ b/tests/transports/postgres.test.js @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { initLogsTable, PostgresTransport, pruneOldLogs } from '../../src/transports/postgres.js'; +import { PostgresTransport, pruneOldLogs } from '../../src/transports/postgres.js'; /** * Create a mock pg Pool @@ -442,59 +442,6 @@ describe('PostgresTransport', () => { }); }); -describe('initLogsTable', () => { - it('should create the logs table and indexes in a transaction', async () => { - const mockPool = createMockPool(); - const client = mockPool._mockClient; - - await initLogsTable(mockPool); - - expect(mockPool.connect).toHaveBeenCalledTimes(1); - expect(client.query).toHaveBeenCalledTimes(5); - - // BEGIN - expect(client.query.mock.calls[0][0]).toBe('BEGIN'); - - // CREATE TABLE - const tableQuery = client.query.mock.calls[1][0]; - expect(tableQuery).toContain('CREATE TABLE IF NOT EXISTS logs'); - expect(tableQuery).toContain('id SERIAL PRIMARY KEY'); - expect(tableQuery).toContain('level VARCHAR(10)'); - expect(tableQuery).toContain('message TEXT'); - expect(tableQuery).toContain('metadata JSONB'); - expect(tableQuery).toContain('TIMESTAMPTZ'); - - // CREATE INDEX x2 - expect(client.query.mock.calls[2][0]).toContain('idx_logs_timestamp'); - expect(client.query.mock.calls[3][0]).toContain('idx_logs_level'); - - // COMMIT - expect(client.query.mock.calls[4][0]).toBe('COMMIT'); - - // Client released - expect(client.release).toHaveBeenCalledTimes(1); - }); - - it('should rollback on error and release client', async () => { - const mockPool = createMockPool(); - const client = mockPool._mockClient; - - // Fail on CREATE TABLE (second call after BEGIN) - client.query - .mockResolvedValueOnce() // BEGIN - .mockRejectedValueOnce(new Error('table creation failed')); - - await expect(initLogsTable(mockPool)).rejects.toThrow('table creation failed'); - - // Should have called ROLLBACK - const calls = client.query.mock.calls.map((c) => c[0]); - expect(calls).toContain('ROLLBACK'); - - // Client released - expect(client.release).toHaveBeenCalledTimes(1); - }); -}); - describe('pruneOldLogs', () => { it('should delete logs older than retention period', async () => { const mockPool = createMockPool({ rows: [], rowCount: 5 }); diff --git a/tests/utils/restartTracker.test.js b/tests/utils/restartTracker.test.js index 407da2b0..09a6b412 100644 --- a/tests/utils/restartTracker.test.js +++ b/tests/utils/restartTracker.test.js @@ -47,7 +47,7 @@ describe('restartTracker', () => { // --------------------------------------------------------------------------- describe('recordRestart', () => { - it('creates the table then inserts a row, returns the new id', async () => { + it('inserts a row and returns the new id', async () => { const pool = makePool({ 'RETURNING id': { rows: [{ id: 42 }] }, }); @@ -55,12 +55,9 @@ describe('restartTracker', () => { const id = await recordRestart(pool, 'startup', '1.0.0'); expect(id).toBe(42); - // First call: CREATE TABLE IF NOT EXISTS - expect(pool.query.mock.calls[0][0]).toContain('CREATE TABLE IF NOT EXISTS bot_restarts'); - // Second call: INSERT - const insertCall = pool.query.mock.calls[1]; - expect(insertCall[0]).toContain('INSERT INTO bot_restarts'); - expect(insertCall[1]).toEqual(['startup', '1.0.0']); + // Should directly INSERT (no ensureTable call) + expect(pool.query.mock.calls[0][0]).toContain('INSERT INTO bot_restarts'); + expect(pool.query.mock.calls[0][1]).toEqual(['startup', '1.0.0']); }); it('sets startedAt to a recent timestamp', async () => { @@ -80,7 +77,7 @@ describe('restartTracker', () => { await recordRestart(pool); - const insertCall = pool.query.mock.calls[1]; + const insertCall = pool.query.mock.calls[0]; expect(insertCall[1]).toEqual(['startup', null]); }); @@ -196,70 +193,6 @@ describe('restartTracker', () => { expect.objectContaining({ error: 'oops' }), ); }); - - it('self-heals by creating table on 42P01 then retries successfully', async () => { - const rows = [ - { id: 1, timestamp: new Date(), reason: 'startup', version: '1.0.0', uptime_seconds: 60 }, - ]; - let selectCallCount = 0; - const pool = { - query: vi.fn(async (sql) => { - if (sql.includes('FROM bot_restarts')) { - selectCallCount++; - if (selectCallCount === 1) { - const err = new Error('relation "bot_restarts" does not exist'); - err.code = '42P01'; - throw err; - } - // Retry SELECT succeeds - return { rows }; - } - // CREATE TABLE call - if (sql.includes('CREATE TABLE')) { - return { rows: [], rowCount: 0 }; - } - return { rows: [], rowCount: 0 }; - }), - }; - - const result = await getRestarts(pool); - - expect(result).toEqual(rows); - // Should have called: SELECT (fail), CREATE TABLE, SELECT (success) - expect(pool.query).toHaveBeenCalledTimes(3); - }); - - it('returns [] and logs error when retry SELECT also fails after 42P01 self-heal', async () => { - const { error: logError } = await import('../../src/logger.js'); - let selectCallCount = 0; - const pool = { - query: vi.fn(async (sql) => { - if (sql.includes('FROM bot_restarts')) { - selectCallCount++; - if (selectCallCount === 1) { - const err = new Error('relation "bot_restarts" does not exist'); - err.code = '42P01'; - throw err; - } - // Retry also fails - throw new Error('still broken'); - } - // CREATE TABLE succeeds - if (sql.includes('CREATE TABLE')) { - return { rows: [], rowCount: 0 }; - } - return { rows: [], rowCount: 0 }; - }), - }; - - const result = await getRestarts(pool); - - expect(result).toEqual([]); - expect(logError).toHaveBeenCalledWith( - 'Failed to query restarts after table creation', - expect.objectContaining({ error: 'still broken' }), - ); - }); }); // ---------------------------------------------------------------------------