#include "storage/database.hpp" #include "util/file_util.hpp" #include "util/id_util.hpp" #include #include namespace lm { namespace { bool execSql(sqlite3* db, const char* sql, std::string& err) { char* msg = nullptr; const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &msg); if (rc != SQLITE_OK) { err = msg ? msg : sqlite3_errstr(rc); sqlite3_free(msg); return false; } return true; } const char* kSchemaSql = R"SQL( CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS documents ( name TEXT PRIMARY KEY, content TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS layout_profiles ( id TEXT PRIMARY KEY, content TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sites ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS maps ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', site_id TEXT, created_by TEXT NOT NULL DEFAULT '', created_by_user TEXT, created_by_group TEXT, width REAL, height REAL, resolution REAL, origin_x REAL DEFAULT 0, origin_y REAL DEFAULT 0, origin_yaw REAL DEFAULT 0, image_file TEXT, yaml_file TEXT, zones_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS sounds ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', file_name TEXT, duration_ms INTEGER, enabled INTEGER NOT NULL DEFAULT 1, volume INTEGER NOT NULL DEFAULT 100, is_system INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS recordings ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', map_id TEXT, file_path TEXT, started_at TEXT, ended_at TEXT, created_at TEXT NOT NULL, FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS transitions ( id TEXT PRIMARY KEY, site_id TEXT NOT NULL, from_map_id TEXT NOT NULL, to_map_id TEXT NOT NULL, start_position_id TEXT NOT NULL, goal_position_id TEXT NOT NULL, mission_id TEXT NOT NULL, created_by TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS io_modules ( id TEXT PRIMARY KEY, site_id TEXT NOT NULL, name TEXT NOT NULL, module_type TEXT NOT NULL, ip_address TEXT NOT NULL, created_by TEXT NOT NULL DEFAULT '', created_by_group TEXT NOT NULL DEFAULT '', connected INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS dashboards ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_by TEXT NOT NULL DEFAULT '', created_by_user TEXT, is_default INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS dashboard_edit_groups ( dashboard_id TEXT NOT NULL, group_id TEXT NOT NULL, PRIMARY KEY (dashboard_id, group_id), FOREIGN KEY (dashboard_id) REFERENCES dashboards(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS dashboard_widgets ( id TEXT PRIMARY KEY, dashboard_id TEXT NOT NULL, type TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', mission_id TEXT, mission_group TEXT, config_json TEXT NOT NULL DEFAULT '{}', sort_order INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (dashboard_id) REFERENCES dashboards(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS dashboard_state ( id INTEGER PRIMARY KEY CHECK (id = 1), active_dashboard_id TEXT ); )SQL"; } // namespace Database::Database(std::filesystem::path data_dir) : data_dir_(std::move(data_dir)), db_path_(data_dir_ / "RBS.db") { } void Database::close() { if (db_) { sqlite3_close(db_); db_ = nullptr; } } bool Database::openDb(std::string& err) { std::error_code ec; std::filesystem::create_directories(data_dir_, ec); const auto legacy_path = data_dir_ / "test3.db"; if (!std::filesystem::exists(db_path_) && std::filesystem::exists(legacy_path)) { std::filesystem::rename(legacy_path, db_path_, ec); for (const char* suffix : {"-wal", "-shm"}) { const auto from = legacy_path.string() + suffix; const auto to = db_path_.string() + suffix; if (std::filesystem::exists(from)) std::filesystem::rename(from, to, ec); } } const int rc = sqlite3_open(db_path_.string().c_str(), &db_); if (rc != SQLITE_OK) { err = sqlite3_errmsg(db_); db_ = nullptr; return false; } sqlite3_busy_timeout(db_, 5000); if (!execSql(db_, "PRAGMA journal_mode=WAL;", err)) return false; if (!execSql(db_, "PRAGMA synchronous=NORMAL;", err)) return false; if (!execSql(db_, "PRAGMA foreign_keys=ON;", err)) return false; return true; } bool Database::ensureDataDirs(std::string& err) { std::error_code ec; for (const auto& dir : {mapsDir(), soundsDir(), recordingsDir()}) { if (!std::filesystem::create_directories(dir, ec) && ec) { err = "failed to create directory: " + dir.string(); return false; } } return true; } bool Database::migrateLegacyMapsDir(std::string& err) { if (data_dir_.filename() != "data") return true; const std::filesystem::path legacy = "maps"; const auto target = mapsDir(); if (!std::filesystem::is_directory(legacy) || legacy == target) return true; std::error_code ec; std::filesystem::create_directories(target, ec); if (ec) { err = "failed to create maps directory: " + target.string(); return false; } for (const auto& entry : std::filesystem::directory_iterator(legacy, ec)) { if (ec || !entry.is_directory()) continue; const auto dest = target / entry.path().filename(); if (std::filesystem::exists(dest)) continue; std::filesystem::rename(entry.path(), dest, ec); if (ec) { ec.clear(); std::filesystem::copy(entry.path(), dest, std::filesystem::copy_options::recursive, ec); if (ec) { err = "failed to migrate map directory: " + entry.path().string(); return false; } std::filesystem::remove_all(entry.path(), ec); ec.clear(); } } return true; } bool Database::applySchema(std::string& err) { return execSql(db_, kSchemaSql, err); } namespace { bool tableHasColumn(sqlite3* db, const char* table, const char* column) { std::string sql = "PRAGMA table_info("; sql += table; sql += ")"; sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) return false; bool found = false; while (sqlite3_step(stmt) == SQLITE_ROW) { const char* name = reinterpret_cast(sqlite3_column_text(stmt, 1)); if (name && column == std::string(name)) { found = true; break; } } sqlite3_finalize(stmt); return found; } } // namespace bool Database::applySchemaMigrations(std::string& err) { std::string ver = getMeta("schema_version").value_or("1"); if (ver == "1") { if (!execSql(db_, "CREATE TABLE IF NOT EXISTS sites (" "id TEXT PRIMARY KEY, name TEXT NOT NULL, " "created_at TEXT NOT NULL, updated_at TEXT NOT NULL)", err)) return false; if (!tableHasColumn(db_, "maps", "site_id")) { if (!execSql(db_, "ALTER TABLE maps ADD COLUMN site_id TEXT", err)) return false; } if (!tableHasColumn(db_, "maps", "created_by")) { if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err)) return false; } const std::string now = IdUtil::nowIso8601(); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_, "INSERT OR IGNORE INTO sites(id, name, created_at, updated_at) " "VALUES('site_configuration', 'ConfigurationSite', ?1, ?1)", -1, &stmt, nullptr) == SQLITE_OK) { sqlite3_bind_text(stmt, 1, now.c_str(), -1, SQLITE_TRANSIENT); sqlite3_step(stmt); sqlite3_finalize(stmt); } if (sqlite3_prepare_v2(db_, "UPDATE maps SET site_id = 'site_configuration' WHERE site_id IS NULL OR site_id = ''", -1, &stmt, nullptr) == SQLITE_OK) { sqlite3_step(stmt); sqlite3_finalize(stmt); } setMeta("schema_version", "2"); } ver = getMeta("schema_version").value_or("1"); if (ver == "2") { if (!tableHasColumn(db_, "maps", "created_by_user")) { if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by_user TEXT", err)) return false; } if (!tableHasColumn(db_, "maps", "created_by_group")) { if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by_group TEXT", err)) return false; } setMeta("schema_version", "3"); } ver = getMeta("schema_version").value_or("1"); if (ver == "3") { if (!tableHasColumn(db_, "sounds", "volume")) { if (!execSql(db_, "ALTER TABLE sounds ADD COLUMN volume INTEGER NOT NULL DEFAULT 100", err)) return false; } if (!tableHasColumn(db_, "sounds", "is_system")) { if (!execSql(db_, "ALTER TABLE sounds ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0", err)) return false; } setMeta("schema_version", "4"); } ver = getMeta("schema_version").value_or("1"); if (ver == "4") { if (!execSql(db_, "CREATE TABLE IF NOT EXISTS transitions (" "id TEXT PRIMARY KEY, " "site_id TEXT NOT NULL, " "from_map_id TEXT NOT NULL, " "to_map_id TEXT NOT NULL, " "start_position_id TEXT NOT NULL, " "goal_position_id TEXT NOT NULL, " "mission_id TEXT NOT NULL, " "created_by TEXT NOT NULL DEFAULT '', " "created_at TEXT NOT NULL, " "updated_at TEXT NOT NULL" ")", err)) return false; setMeta("schema_version", "5"); } ver = getMeta("schema_version").value_or("1"); if (ver == "5") { if (!execSql(db_, "ALTER TABLE transitions ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err)) return false; setMeta("schema_version", "6"); } ver = getMeta("schema_version").value_or("1"); if (ver == "6") { if (!execSql(db_, "CREATE TABLE IF NOT EXISTS io_modules (" "id TEXT PRIMARY KEY, " "site_id TEXT NOT NULL, " "name TEXT NOT NULL, " "module_type TEXT NOT NULL, " "ip_address TEXT NOT NULL, " "created_by TEXT NOT NULL DEFAULT '', " "created_by_group TEXT NOT NULL DEFAULT '', " "connected INTEGER NOT NULL DEFAULT 0, " "created_at TEXT NOT NULL, " "updated_at TEXT NOT NULL, " "FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE" ")", err)) return false; setMeta("schema_version", "7"); } return true; } std::optional Database::getMeta(const std::string& key) const { sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_, "SELECT value FROM meta WHERE key = ?1", -1, &stmt, nullptr) != SQLITE_OK) return std::nullopt; sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT); std::optional out; if (sqlite3_step(stmt) == SQLITE_ROW) { const char* val = reinterpret_cast(sqlite3_column_text(stmt, 0)); if (val) out = val; } sqlite3_finalize(stmt); return out; } bool Database::setMeta(const std::string& key, const std::string& value) { sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_, "INSERT INTO meta(key, value) VALUES(?1, ?2) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", -1, &stmt, nullptr) != SQLITE_OK) return false; sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, value.c_str(), -1, SQLITE_TRANSIENT); const bool ok = sqlite3_step(stmt) == SQLITE_DONE; sqlite3_finalize(stmt); return ok; } bool Database::getDocument(const std::string& name, nlohmann::json& out) const { std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_, "SELECT content FROM documents WHERE name = ?1", -1, &stmt, nullptr) != SQLITE_OK) return false; sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT); bool found = false; if (sqlite3_step(stmt) == SQLITE_ROW) { const char* text = reinterpret_cast(sqlite3_column_text(stmt, 0)); if (text) { try { out = nlohmann::json::parse(text); found = true; } catch (...) { found = false; } } } sqlite3_finalize(stmt); return found; } bool Database::setDocument(const std::string& name, const nlohmann::json& doc) { std::lock_guard lock(mu_); const std::string now = IdUtil::nowIso8601(); const std::string body = doc.dump(); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_, "INSERT INTO documents(name, content, updated_at) VALUES(?1, ?2, ?3) " "ON CONFLICT(name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at", -1, &stmt, nullptr) != SQLITE_OK) return false; sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, body.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT); const bool ok = sqlite3_step(stmt) == SQLITE_DONE; sqlite3_finalize(stmt); return ok; } std::optional Database::getLayoutProfile(const std::string& id) const { std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_, "SELECT content FROM layout_profiles WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK) return std::nullopt; sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); std::optional out; if (sqlite3_step(stmt) == SQLITE_ROW) { const char* text = reinterpret_cast(sqlite3_column_text(stmt, 0)); if (text) { try { out = nlohmann::json::parse(text); } catch (...) { out = std::nullopt; } } } sqlite3_finalize(stmt); return out; } bool Database::setLayoutProfile(const nlohmann::json& profile) { if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string()) return false; const std::string id = profile["id"].get(); const std::string now = IdUtil::nowIso8601(); const std::string body = profile.dump(); std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_, "INSERT INTO layout_profiles(id, content, updated_at) VALUES(?1, ?2, ?3) " "ON CONFLICT(id) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at", -1, &stmt, nullptr) != SQLITE_OK) return false; sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, body.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT); const bool ok = sqlite3_step(stmt) == SQLITE_DONE; sqlite3_finalize(stmt); return ok; } bool Database::deleteLayoutProfile(const std::string& id) { std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_, "DELETE FROM layout_profiles WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK) return false; sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); const bool ok = sqlite3_step(stmt) == SQLITE_DONE; sqlite3_finalize(stmt); return ok; } bool Database::migrateFromJsonIfNeeded(std::string& err) { if (getMeta("json_imported")) return true; const auto importDoc = [&](const std::string& name, const std::filesystem::path& path) { if (!std::filesystem::exists(path)) return; try { const auto doc = nlohmann::json::parse(FileUtil::readBinary(path)); setDocument(name, doc); } catch (...) { } }; importDoc("auth", data_dir_ / "auth.json"); importDoc("missions", data_dir_ / "missions.json"); importDoc("mission_queue", data_dir_ / "mission_queue.json"); importDoc("robot_runtime", data_dir_ / "robot_runtime.json"); const auto state_path = data_dir_ / "state.json"; if (std::filesystem::exists(state_path)) { try { setDocument("state", nlohmann::json::parse(FileUtil::readBinary(state_path))); } catch (...) { } } const auto models_dir = data_dir_ / "models"; if (std::filesystem::is_directory(models_dir)) { for (const auto& entry : std::filesystem::directory_iterator(models_dir)) { if (!entry.is_regular_file() || entry.path().extension() != ".json") continue; try { const auto profile = nlohmann::json::parse(FileUtil::readBinary(entry.path())); setLayoutProfile(profile); } catch (...) { } } } setMeta("schema_version", "1"); setMeta("json_imported", IdUtil::nowIso8601()); std::fprintf(stderr, "SQLite: imported JSON data into %s\n", db_path_.string().c_str()); return true; } bool Database::init(std::string& err) { if (!openDb(err)) return false; if (!applySchema(err)) return false; if (!applySchemaMigrations(err)) return false; if (!ensureDataDirs(err)) return false; if (!migrateLegacyMapsDir(err)) return false; if (!migrateFromJsonIfNeeded(err)) return false; if (!getMeta("schema_version")) setMeta("schema_version", "1"); return true; } } // namespace lm