package shine.db; import utils.config.AppConfig; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public final class SqliteDbController { private static volatile SqliteDbController instance; private static final int LATEST_SCHEMA_VERSION = 2; private final String jdbcUrl; private SqliteDbController() { try { Class.forName("org.sqlite.JDBC"); } catch (ClassNotFoundException e) { throw new RuntimeException("SQLite JDBC driver not found", e); } String dbPath = AppConfig.getInstance().getParam("db.path"); if (dbPath == null || dbPath.isBlank()) { throw new RuntimeException("Config param 'db.path' is not set in application.properties"); } Path dbFile = Paths.get(dbPath); if (!Files.exists(dbFile)) { System.out.println("[DB] Файл БД не найден: " + dbFile.toAbsolutePath()); System.out.println("[DB] Создаём новую БД с помощью DatabaseInitializer..."); DatabaseInitializer.createNewDB(new String[0]); } this.jdbcUrl = "jdbc:sqlite:" + dbPath; ensureSchemaMigrations(); } public static SqliteDbController getInstance() { if (instance == null) { synchronized (SqliteDbController.class) { if (instance == null) { instance = new SqliteDbController(); } } } return instance; } public Connection getConnection() throws SQLException { Connection conn = DriverManager.getConnection(jdbcUrl); conn.setAutoCommit(true); try (Statement st = conn.createStatement()) { st.execute("PRAGMA foreign_keys = ON"); st.execute("PRAGMA journal_mode = WAL"); st.execute("PRAGMA synchronous = NORMAL"); st.execute("PRAGMA busy_timeout = 5000"); } return conn; } public void close() { // no-op } private void ensureSchemaMigrations() { int currentVersion = getCurrentSchemaVersion(); while (currentVersion < LATEST_SCHEMA_VERSION) { int nextVersion = currentVersion + 1; applyMigration(nextVersion); currentVersion = nextVersion; } } private void applyMigration(int targetVersion) { switch (targetVersion) { case 1 -> migrateToV1(); case 2 -> migrateToV2(); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); } } private void migrateToV1() { try { DatabaseInitializer.ensureSchemaV1Structure(jdbcUrl); } catch (SQLException e) { throw new RuntimeException("DB migration to v1 failed (base schema)", e); } try (Connection c = DriverManager.getConnection(jdbcUrl); Statement st = c.createStatement()) { c.setAutoCommit(false); try { st.execute("PRAGMA foreign_keys = OFF"); if (tableExists(c, "connections_state") && needsConnectionsStateUpgrade(c)) { rebuildConnectionsStateTable(st); } ensureChannelNamesDescriptionColumn(c, st); ensureSignedMessageReceiptUniq(c, st); DatabaseTriggersInstaller.createAllTriggers(st); setSchemaVersion(c, 1); st.execute("PRAGMA foreign_keys = ON"); c.commit(); } catch (Exception e) { try { c.rollback(); } catch (Exception ignored) {} throw new RuntimeException("DB migration to v1 failed", e); } finally { try { c.setAutoCommit(true); } catch (Exception ignored) {} } } catch (SQLException e) { throw new RuntimeException("DB migration to v1 failed", e); } } private void migrateToV2() { try (Connection c = DriverManager.getConnection(jdbcUrl); Statement st = c.createStatement()) { c.setAutoCommit(false); try { st.execute("PRAGMA foreign_keys = OFF"); st.executeUpdate("DROP TABLE IF EXISTS message_views_state"); st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_target"); st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_viewer_channel"); setSchemaVersion(c, 2); st.execute("PRAGMA foreign_keys = ON"); c.commit(); } catch (Exception e) { try { c.rollback(); } catch (Exception ignored) {} throw new RuntimeException("DB migration to v2 failed", e); } finally { try { c.setAutoCommit(true); } catch (Exception ignored) {} } } catch (SQLException e) { throw new RuntimeException("DB migration to v2 failed", e); } } private int getCurrentSchemaVersion() { try (Connection c = DriverManager.getConnection(jdbcUrl)) { if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) { return 0; } try (var ps = c.prepareStatement(""" SELECT schema_version FROM db_schema_version WHERE id = 1 LIMIT 1 """); ResultSet rs = ps.executeQuery()) { if (rs.next()) { return rs.getInt(1); } } return 0; } catch (SQLException e) { throw new RuntimeException("Cannot read DB schema version", e); } } private static void setSchemaVersion(Connection c, int version) throws SQLException { try (var ps = c.prepareStatement(""" INSERT INTO db_schema_version (id, schema_version, updated_at_ms) VALUES (1, ?, CAST(strftime('%s','now') AS INTEGER) * 1000) ON CONFLICT(id) DO UPDATE SET schema_version = excluded.schema_version, updated_at_ms = excluded.updated_at_ms """)) { ps.setInt(1, version); ps.executeUpdate(); } } private static void ensureReactionsStateTable(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS reactions_state ( from_login TEXT NOT NULL, from_bch_name TEXT NOT NULL, reaction_type INTEGER NOT NULL, to_login TEXT NOT NULL, to_bch_name TEXT NOT NULL, to_block_number INTEGER NOT NULL, to_block_hash BLOB NOT NULL, last_sub_type INTEGER NOT NULL, UNIQUE ( from_login, from_bch_name, reaction_type, to_login, to_bch_name, to_block_number, to_block_hash ) ); """); } private static void createConnectionsStateTable(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS connections_state ( login TEXT NOT NULL, rel_type INTEGER NOT NULL, to_login TEXT NOT NULL, to_bch_name TEXT NOT NULL, to_block_number INTEGER NOT NULL, to_block_hash BLOB NOT NULL, FOREIGN KEY (login) REFERENCES solana_users(login), UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) ); """); } private static void ensureConnectionsIndexes(Statement st) throws SQLException { st.executeUpdate(""" CREATE INDEX IF NOT EXISTS idx_connections_state_login ON connections_state (login); """); st.executeUpdate(""" CREATE INDEX IF NOT EXISTS idx_connections_state_to_login ON connections_state (to_login); """); st.executeUpdate(""" CREATE INDEX IF NOT EXISTS idx_connections_state_pair ON connections_state (login, to_login); """); st.executeUpdate(""" CREATE INDEX IF NOT EXISTS idx_connections_state_target ON connections_state (login, rel_type, to_bch_name, to_block_number); """); } private static void ensureReactionsIndexes(Statement st) throws SQLException { st.executeUpdate(""" CREATE INDEX IF NOT EXISTS idx_reactions_state_target ON reactions_state (to_bch_name, to_block_number, to_block_hash); """); st.executeUpdate(""" CREATE INDEX IF NOT EXISTS idx_reactions_state_actor ON reactions_state (from_login, from_bch_name, reaction_type); """); } private static void ensureChannelNamesStateTable(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS channel_names_state ( slug TEXT NOT NULL PRIMARY KEY, display_name TEXT NOT NULL, channel_description TEXT NOT NULL DEFAULT '', owner_login TEXT NOT NULL, owner_bch_name TEXT NOT NULL, channel_root_block_number INTEGER NOT NULL, channel_root_block_hash BLOB NOT NULL, created_at_ms INTEGER NOT NULL ); """); } private static void ensureChannelNamesDescriptionColumn(Connection c, Statement st) throws SQLException { boolean hasDescription = false; try (Statement probe = c.createStatement(); ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) { while (rs.next()) { String name = rs.getString("name"); if ("channel_description".equalsIgnoreCase(name)) { hasDescription = true; break; } } } if (!hasDescription) { st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_description TEXT NOT NULL DEFAULT ''"); } } private static void ensureChannelNamesIndexes(Statement st) throws SQLException { st.executeUpdate(""" CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug ON channel_names_state (slug); """); st.executeUpdate(""" CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash); """); st.executeUpdate(""" CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner ON channel_names_state (owner_login, owner_bch_name); """); } private static void ensureSignedMessageReceiptUniq(Connection c, Statement st) throws SQLException { if (!tableExists(c, "signed_messages_v2")) return; if (tableExists(c, "signed_message_session_delivery")) { st.executeUpdate(""" DELETE FROM signed_message_session_delivery WHERE message_key IN ( SELECT message_key FROM signed_messages_v2 WHERE message_type IN (3, 4) AND receipt_ref_base_key IS NOT NULL AND rowid NOT IN ( SELECT MIN(rowid) FROM signed_messages_v2 WHERE message_type IN (3, 4) AND receipt_ref_base_key IS NOT NULL GROUP BY target_login, message_type, receipt_ref_base_key ) ); """); } st.executeUpdate(""" DELETE FROM signed_messages_v2 WHERE message_type IN (3, 4) AND receipt_ref_base_key IS NOT NULL AND rowid NOT IN ( SELECT MIN(rowid) FROM signed_messages_v2 WHERE message_type IN (3, 4) AND receipt_ref_base_key IS NOT NULL GROUP BY target_login, message_type, receipt_ref_base_key ); """); st.executeUpdate(""" CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming ON signed_messages_v2 (target_login, receipt_ref_base_key) WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL; """); st.executeUpdate(""" CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing ON signed_messages_v2 (target_login, receipt_ref_base_key) WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL; """); } private static void rebuildConnectionsStateTable(Statement st) throws SQLException { st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2"); st.executeUpdate(""" CREATE TABLE connections_state_v2 ( login TEXT NOT NULL, rel_type INTEGER NOT NULL, to_login TEXT NOT NULL, to_bch_name TEXT NOT NULL, to_block_number INTEGER NOT NULL, to_block_hash BLOB NOT NULL, FOREIGN KEY (login) REFERENCES solana_users(login), UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) ); """); st.executeUpdate(""" INSERT OR IGNORE INTO connections_state_v2 (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) SELECT login, rel_type, to_login, to_bch_name, COALESCE(to_block_number, 0), COALESCE(to_block_hash, zeroblob(32)) FROM connections_state WHERE login IS NOT NULL AND to_login IS NOT NULL AND to_bch_name IS NOT NULL; """); st.executeUpdate("DROP TABLE connections_state"); st.executeUpdate("ALTER TABLE connections_state_v2 RENAME TO connections_state"); } private static boolean tableExists(Connection c, String tableName) throws SQLException { String sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1"; try (var ps = c.prepareStatement(sql)) { ps.setString(1, tableName); try (ResultSet rs = ps.executeQuery()) { return rs.next(); } } } private static boolean needsConnectionsStateUpgrade(Connection c) throws SQLException { boolean toBlockNumberNotNull = false; boolean toBlockHashNotNull = false; try (Statement st = c.createStatement(); ResultSet rs = st.executeQuery("PRAGMA table_info(connections_state)")) { while (rs.next()) { String name = rs.getString("name"); int notNull = rs.getInt("notnull"); if ("to_block_number".equalsIgnoreCase(name)) { toBlockNumberNotNull = notNull == 1; } if ("to_block_hash".equalsIgnoreCase(name)) { toBlockHashNotNull = notNull == 1; } } } return !toBlockNumberNotNull || !toBlockHashNotNull; } }