const path = require("path"); const fs = require("fs"); const Database = require("better-sqlite3"); const dataDir = process.env.LUMI_DATA_DIR ? path.resolve(process.env.LUMI_DATA_DIR) : path.join(__dirname, "..", "..", "data"); const dbPath = path.join(dataDir, "app.db"); fs.mkdirSync(dataDir, { recursive: true }); const db = new Database(dbPath); db.pragma("journal_mode = WAL"); function migrate() { db.exec(` CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS custom_themes ( id TEXT PRIMARY KEY, name TEXT NOT NULL, base_theme_id TEXT NOT NULL, values_json TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, avatar TEXT, last_login INTEGER NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS user_profiles ( id TEXT PRIMARY KEY, internal_username TEXT NOT NULL UNIQUE COLLATE NOCASE, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS user_identities ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, provider TEXT NOT NULL, provider_user_id TEXT NOT NULL, display_name TEXT, avatar TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(provider, provider_user_id) ); CREATE TABLE IF NOT EXISTS mod_role_periods ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, start_at INTEGER NOT NULL, end_at INTEGER ); CREATE TABLE IF NOT EXISTS linked_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, provider TEXT NOT NULL, provider_user_id TEXT NOT NULL, display_name TEXT, access_token TEXT, refresh_token TEXT, expires_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(user_id, provider) ); CREATE TABLE IF NOT EXISTS plugins ( id TEXT PRIMARY KEY, name TEXT NOT NULL, version TEXT, enabled INTEGER NOT NULL DEFAULT 1, source TEXT, path TEXT NOT NULL, installed_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS plugin_settings ( plugin_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (plugin_id, key) ); CREATE TABLE IF NOT EXISTS stats ( user_id TEXT PRIMARY KEY, messages INTEGER NOT NULL DEFAULT 0, commands INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS user_age_intervals ( id TEXT PRIMARY KEY, platform TEXT NOT NULL, platform_user_id TEXT NOT NULL, scope_id TEXT NOT NULL, statistic_type TEXT NOT NULL, start_at INTEGER NOT NULL, end_at INTEGER, start_source TEXT NOT NULL, start_precision TEXT NOT NULL DEFAULT 'millisecond', start_authoritative INTEGER NOT NULL DEFAULT 0, end_source TEXT, end_precision TEXT, end_authoritative INTEGER, observed_at INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS user_age_intervals_one_open_idx ON user_age_intervals (platform, platform_user_id, scope_id, statistic_type) WHERE end_at IS NULL; CREATE INDEX IF NOT EXISTS user_age_intervals_lookup_idx ON user_age_intervals (platform, scope_id, statistic_type, platform_user_id, start_at); CREATE TABLE IF NOT EXISTS user_age_events ( platform TEXT NOT NULL, event_id TEXT NOT NULL, processed_at INTEGER NOT NULL, PRIMARY KEY (platform, event_id) ); CREATE TABLE IF NOT EXISTS custom_pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, nav_label TEXT, content TEXT NOT NULL, content_css TEXT NOT NULL DEFAULT '', format TEXT NOT NULL DEFAULT 'html', role TEXT NOT NULL DEFAULT 'public', show_in_nav INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS custom_commands ( id INTEGER PRIMARY KEY AUTOINCREMENT, trigger TEXT NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', response TEXT NOT NULL, platform TEXT NOT NULL DEFAULT 'both', mode TEXT NOT NULL DEFAULT 'plain', language TEXT NOT NULL DEFAULT 'js', code TEXT, preview_text TEXT, preview_status TEXT, preview_error TEXT, preview_generated_at INTEGER, preview_dynamic_segments TEXT NOT NULL DEFAULT '[]', random_replies_json TEXT NOT NULL DEFAULT '[]', conditional_replies_json TEXT NOT NULL DEFAULT '[]', conditional_fuzzy INTEGER NOT NULL DEFAULT 1, rng_enabled INTEGER NOT NULL DEFAULT 0, rng_min INTEGER NOT NULL DEFAULT 1, rng_max INTEGER NOT NULL DEFAULT 100, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS command_usage ( command_id TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS command_user_usage ( command_id TEXT NOT NULL, user_id TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (command_id, user_id), FOREIGN KEY (user_id) REFERENCES user_profiles(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_command_user_usage_user ON command_user_usage(user_id, count DESC, updated_at DESC); CREATE TABLE IF NOT EXISTS command_groups ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE, description TEXT NOT NULL DEFAULT '', policy_json TEXT NOT NULL DEFAULT '{}', is_default INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS command_group_members ( group_id TEXT NOT NULL, command_key TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (group_id, command_key), FOREIGN KEY (group_id) REFERENCES command_groups(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS command_policies ( command_key TEXT PRIMARY KEY, policy_json TEXT NOT NULL DEFAULT '{}', updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS command_policy_uses ( id TEXT NOT NULL, command_key TEXT NOT NULL, rule_key TEXT NOT NULL, user_id TEXT, stream_session_id TEXT, used_at INTEGER NOT NULL, PRIMARY KEY (id, rule_key) ); CREATE INDEX IF NOT EXISTS command_policy_uses_window_idx ON command_policy_uses (rule_key, user_id, used_at); CREATE INDEX IF NOT EXISTS command_policy_uses_stream_idx ON command_policy_uses (rule_key, stream_session_id, user_id); CREATE TABLE IF NOT EXISTS command_stream_sessions ( stream_key TEXT PRIMARY KEY, session_id TEXT NOT NULL, provider_stream_id TEXT, started_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, level TEXT NOT NULL, message TEXT NOT NULL, details TEXT, source TEXT NOT NULL DEFAULT 'core', category TEXT NOT NULL DEFAULT 'general', event TEXT, request_id TEXT, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS logs_created_at_idx ON logs (created_at); CREATE TABLE IF NOT EXISTS feedback_entries ( id TEXT PRIMARY KEY, submitter_id TEXT NOT NULL, summary TEXT NOT NULL, category TEXT NOT NULL, severity TEXT NOT NULL, scope_type TEXT NOT NULL, scope_label TEXT, target_metadata_json TEXT NOT NULL DEFAULT '{}', current_url TEXT, page_title TEXT, description TEXT NOT NULL, steps_to_reproduce TEXT, expected_behavior TEXT, actual_behavior TEXT, diagnostics_json TEXT NOT NULL DEFAULT '{}', screenshot_path TEXT, screenshot_mime TEXT, screenshot_size INTEGER, status TEXT NOT NULL DEFAULT 'new', admin_reply TEXT, assigned_admin_id TEXT, linked_todo TEXT, linked_issue TEXT, linked_correction TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_activity_at INTEGER NOT NULL, deleted_at INTEGER ); CREATE INDEX IF NOT EXISTS feedback_entries_submitter_idx ON feedback_entries (submitter_id); CREATE INDEX IF NOT EXISTS feedback_entries_status_idx ON feedback_entries (status); CREATE INDEX IF NOT EXISTS feedback_entries_last_activity_idx ON feedback_entries (last_activity_at); CREATE TABLE IF NOT EXISTS feedback_comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, feedback_id TEXT NOT NULL, actor_id TEXT NOT NULL, kind TEXT NOT NULL, body TEXT NOT NULL, visible_to_submitter INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS feedback_comments_feedback_idx ON feedback_comments (feedback_id, created_at); CREATE TABLE IF NOT EXISTS feedback_status_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, feedback_id TEXT NOT NULL, status TEXT NOT NULL, actor_id TEXT, note TEXT, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS feedback_status_history_feedback_idx ON feedback_status_history (feedback_id, created_at); CREATE TABLE IF NOT EXISTS feedback_view_state ( user_id TEXT PRIMARY KEY, last_seen_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS feedback_support ( feedback_id TEXT NOT NULL, user_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (feedback_id, user_id) ); CREATE INDEX IF NOT EXISTS feedback_support_feedback_idx ON feedback_support (feedback_id, created_at); CREATE TABLE IF NOT EXISTS feedback_merges ( source_feedback_id TEXT PRIMARY KEY, target_feedback_id TEXT NOT NULL, merged_by TEXT, reason TEXT, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS feedback_merges_target_idx ON feedback_merges (target_feedback_id, created_at); CREATE TABLE IF NOT EXISTS feedback_attachments ( id TEXT PRIMARY KEY, feedback_id TEXT NOT NULL, storage_path TEXT NOT NULL, original_name TEXT, mime TEXT NOT NULL, size INTEGER NOT NULL, kind TEXT NOT NULL DEFAULT 'attachment', created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS feedback_attachments_feedback_idx ON feedback_attachments (feedback_id, created_at); CREATE TABLE IF NOT EXISTS overlays ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1, canvas_width INTEGER NOT NULL DEFAULT 1920, canvas_height INTEGER NOT NULL DEFAULT 1080, sort_order INTEGER NOT NULL DEFAULT 0, active_scene_id TEXT, public_token_hash TEXT NOT NULL UNIQUE, public_token_encrypted TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS overlays_sort_idx ON overlays (sort_order, created_at); CREATE TABLE IF NOT EXISTS overlay_scenes ( id TEXT PRIMARY KEY, overlay_id TEXT NOT NULL, name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0, public_token_hash TEXT NOT NULL UNIQUE, public_token_encrypted TEXT NOT NULL, obs_scene_name TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY (overlay_id) REFERENCES overlays(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS overlay_scenes_overlay_idx ON overlay_scenes (overlay_id, sort_order, created_at); CREATE TABLE IF NOT EXISTS overlay_modules ( id TEXT PRIMARY KEY, scene_id TEXT NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0, config_json TEXT NOT NULL DEFAULT '{}', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY (scene_id) REFERENCES overlay_scenes(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS overlay_modules_scene_idx ON overlay_modules (scene_id, sort_order, created_at); CREATE TABLE IF NOT EXISTS overlay_event_hooks ( id TEXT PRIMARY KEY, overlay_id TEXT NOT NULL, event_type TEXT NOT NULL, action_type TEXT NOT NULL, module_id TEXT NOT NULL, duration_seconds REAL NOT NULL DEFAULT 0, filters_json TEXT NOT NULL DEFAULT '{}', enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY (overlay_id) REFERENCES overlays(id) ON DELETE CASCADE, FOREIGN KEY (module_id) REFERENCES overlay_modules(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS overlay_event_hooks_event_idx ON overlay_event_hooks (event_type, enabled); CREATE INDEX IF NOT EXISTS overlay_event_hooks_overlay_idx ON overlay_event_hooks (overlay_id, created_at); CREATE TABLE IF NOT EXISTS content_resources ( id TEXT PRIMARY KEY, display_name TEXT NOT NULL, original_name TEXT NOT NULL, stored_name TEXT NOT NULL UNIQUE, mime TEXT NOT NULL, extension TEXT NOT NULL, category TEXT NOT NULL, preview_kind TEXT NOT NULL, size INTEGER NOT NULL, checksum_sha256 TEXT NOT NULL, access_level TEXT NOT NULL DEFAULT 'locked', public_token_hash TEXT UNIQUE, public_token_encrypted TEXT, uploaded_by TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS content_resources_created_idx ON content_resources (created_at DESC); CREATE INDEX IF NOT EXISTS content_resources_access_idx ON content_resources (access_level, created_at DESC); CREATE INDEX IF NOT EXISTS content_resources_category_idx ON content_resources (category, created_at DESC); CREATE TABLE IF NOT EXISTS overlay_obs_settings ( overlay_id TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0, provider TEXT NOT NULL DEFAULT 'local_obs_websocket', endpoint TEXT NOT NULL DEFAULT 'ws://127.0.0.1:4455', password_encrypted TEXT, sync_direction TEXT NOT NULL DEFAULT 'none', reconnect INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY (overlay_id) REFERENCES overlays(id) ON DELETE CASCADE ); `); const columns = db .prepare("PRAGMA table_info(custom_commands)") .all() .map((column) => column.name); if (!columns.includes("mode")) { db.exec("ALTER TABLE custom_commands ADD COLUMN mode TEXT NOT NULL DEFAULT 'plain'"); } if (!columns.includes("description")) { db.exec("ALTER TABLE custom_commands ADD COLUMN description TEXT NOT NULL DEFAULT ''"); } if (!columns.includes("language")) { db.exec("ALTER TABLE custom_commands ADD COLUMN language TEXT NOT NULL DEFAULT 'js'"); } if (!columns.includes("code")) { db.exec("ALTER TABLE custom_commands ADD COLUMN code TEXT"); } if (!columns.includes("platform")) { db.exec( "ALTER TABLE custom_commands ADD COLUMN platform TEXT NOT NULL DEFAULT 'both'" ); } if (!columns.includes("preview_text")) { db.exec("ALTER TABLE custom_commands ADD COLUMN preview_text TEXT"); } if (!columns.includes("preview_status")) { db.exec("ALTER TABLE custom_commands ADD COLUMN preview_status TEXT"); } if (!columns.includes("preview_error")) { db.exec("ALTER TABLE custom_commands ADD COLUMN preview_error TEXT"); } if (!columns.includes("preview_generated_at")) { db.exec("ALTER TABLE custom_commands ADD COLUMN preview_generated_at INTEGER"); } if (!columns.includes("preview_dynamic_segments")) { db.exec("ALTER TABLE custom_commands ADD COLUMN preview_dynamic_segments TEXT NOT NULL DEFAULT '[]'"); } if (!columns.includes("random_replies_json")) { db.exec("ALTER TABLE custom_commands ADD COLUMN random_replies_json TEXT NOT NULL DEFAULT '[]'"); } if (!columns.includes("conditional_replies_json")) { db.exec("ALTER TABLE custom_commands ADD COLUMN conditional_replies_json TEXT NOT NULL DEFAULT '[]'"); } if (!columns.includes("conditional_fuzzy")) { db.exec("ALTER TABLE custom_commands ADD COLUMN conditional_fuzzy INTEGER NOT NULL DEFAULT 1"); } const commandGroupColumns = db .prepare("PRAGMA table_info(command_groups)") .all() .map((column) => column.name); if (!commandGroupColumns.includes("is_default")) { db.exec("ALTER TABLE command_groups ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0"); } if (!columns.includes("rng_enabled")) { db.exec("ALTER TABLE custom_commands ADD COLUMN rng_enabled INTEGER NOT NULL DEFAULT 0"); } if (!columns.includes("rng_min")) { db.exec("ALTER TABLE custom_commands ADD COLUMN rng_min INTEGER NOT NULL DEFAULT 1"); } if (!columns.includes("rng_max")) { db.exec("ALTER TABLE custom_commands ADD COLUMN rng_max INTEGER NOT NULL DEFAULT 100"); } const pageColumns = db .prepare("PRAGMA table_info(custom_pages)") .all() .map((column) => column.name); if (!pageColumns.includes("content_css")) { db.exec( "ALTER TABLE custom_pages ADD COLUMN content_css TEXT NOT NULL DEFAULT ''" ); } if (!pageColumns.includes("format")) { db.exec( "ALTER TABLE custom_pages ADD COLUMN format TEXT NOT NULL DEFAULT 'html'" ); } const profileColumns = db .prepare("PRAGMA table_info(user_profiles)") .all() .map((column) => column.name); if (!profileColumns.includes("username_updated_at")) { db.exec("ALTER TABLE user_profiles ADD COLUMN username_updated_at INTEGER"); } const feedbackColumns = db .prepare("PRAGMA table_info(feedback_entries)") .all() .map((column) => column.name); if (!feedbackColumns.includes("screenshot_path")) { db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_path TEXT"); } if (!feedbackColumns.includes("screenshot_mime")) { db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_mime TEXT"); } if (!feedbackColumns.includes("screenshot_size")) { db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_size INTEGER"); } const overlayColumns = db .prepare("PRAGMA table_info(overlays)") .all() .map((column) => column.name); if (!overlayColumns.includes("canvas_width")) { db.exec("ALTER TABLE overlays ADD COLUMN canvas_width INTEGER NOT NULL DEFAULT 1920"); } if (!overlayColumns.includes("canvas_height")) { db.exec("ALTER TABLE overlays ADD COLUMN canvas_height INTEGER NOT NULL DEFAULT 1080"); } const logColumns = db .prepare("PRAGMA table_info(logs)") .all() .map((column) => column.name); if (!logColumns.includes("source")) { db.exec("ALTER TABLE logs ADD COLUMN source TEXT NOT NULL DEFAULT 'core'"); } if (!logColumns.includes("category")) { db.exec("ALTER TABLE logs ADD COLUMN category TEXT NOT NULL DEFAULT 'general'"); } if (!logColumns.includes("event")) { db.exec("ALTER TABLE logs ADD COLUMN event TEXT"); } if (!logColumns.includes("request_id")) { db.exec("ALTER TABLE logs ADD COLUMN request_id TEXT"); } db.exec("CREATE INDEX IF NOT EXISTS logs_source_created_at_idx ON logs (source, created_at)"); db.exec("CREATE INDEX IF NOT EXISTS logs_category_created_at_idx ON logs (category, created_at)"); migrateLegacyUsers(); } function migrateLegacyUsers() { const legacyUsers = db .prepare("SELECT id, username, avatar FROM users") .all(); if (!legacyUsers.length) { return; } const now = Date.now(); const mapping = new Map(); for (const legacy of legacyUsers) { const existingIdentity = db .prepare( "SELECT user_id FROM user_identities WHERE provider = 'discord' AND provider_user_id = ?" ) .get(legacy.id); if (existingIdentity?.user_id) { mapping.set(legacy.id, existingIdentity.user_id); continue; } const username = generateUniqueLegacyUsername(legacy.username); const userId = cryptoRandomId(); db.prepare( "INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)" ).run(userId, username, now, now); db.prepare( "INSERT INTO user_identities (user_id, provider, provider_user_id, display_name, avatar, created_at, updated_at) VALUES (?, 'discord', ?, ?, ?, ?, ?)" ).run(userId, legacy.id, legacy.username, legacy.avatar, now, now); mapping.set(legacy.id, userId); } for (const [legacyId, userId] of mapping.entries()) { db.prepare("UPDATE stats SET user_id = ? WHERE user_id = ?").run( userId, legacyId ); db.prepare("UPDATE linked_accounts SET user_id = ? WHERE user_id = ?").run( userId, legacyId ); } } function generateUniqueLegacyUsername(name) { const base = (name || "user").trim() || "user"; if (isLegacyUsernameAvailable(base)) { return base; } let suffix = 2; let candidate = `${base}-${suffix}`; while (!isLegacyUsernameAvailable(candidate)) { suffix += 1; candidate = `${base}-${suffix}`; } return candidate; } function isLegacyUsernameAvailable(name) { const row = db .prepare( "SELECT id FROM user_profiles WHERE internal_username = ? LIMIT 1" ) .get(name); return !row; } function cryptoRandomId() { return require("crypto").randomUUID(); } module.exports = { db, migrate };