#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr auto application_id = std::int64_t{0x56414D50}; constexpr auto statement_delimiter = std::string_view{"-- vamp-statement"}; auto trim(std::string value) -> std::string { auto first = value.find_first_not_of(" \t\r\n"); if (first == std::string::npos) { return {}; } auto last = value.find_last_not_of(" \t\r\n"); return value.substr(first, last - first + 1U); } auto read_text(std::string const& path) -> std::expected { auto input = std::ifstream{path, std::ios::binary}; if (!input) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "failed to open migration: " + path)); } auto text = std::string{std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; if (!input.good() && !input.eof()) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to read migration: " + path)); } return text; } auto execute_statements(noto::connection& connection, std::vector statements) -> boost::asio::awaitable> { for (auto& statement : statements) { auto result = co_await connection.async_execute(std::move(statement)); if (!result) { co_return std::unexpected(result.error()); } } co_return std::expected{}; } auto connection_config(vamp_zero::database_config const& config) -> noto::connection_config { auto result = config.path == ":memory:" ? noto::memory_connection_config("product-vamp") : noto::file_connection_config(config.path, "product-vamp"); result.busy_timeout = std::chrono::seconds{5}; result.foreign_keys = true; result.pragmas = { {.name = "synchronous", .value = std::string{"NORMAL"}}, {.name = "temp_store", .value = std::string{"MEMORY"}}, }; return result; } auto read_migration(vamp_zero::database_config const& config, std::string filename) -> std::expected, modulus::error> { auto migration_text = read_text(config.migrations_path + "/" + filename); if (!migration_text) { return std::unexpected(migration_text.error()); } return vamp_zero::split_migration_statements(*migration_text); } auto text_value(noto::sqlite_value const& value) -> std::string { auto text = std::get_if(&value.value); return text == nullptr ? std::string{} : *text; } auto path_parts(std::filesystem::path const& path) -> std::vector { auto parts = std::vector{}; for (auto const& part : path) { parts.push_back(part.string()); } return parts; } auto logical_practice_exercise_id(std::string const& type_id, std::string const& lesson_id, std::string const& source_path, std::string const& fallback) -> std::string { auto parts = path_parts(std::filesystem::path{source_path}); for (auto index = std::size_t{}; index + 3U < parts.size(); ++index) { if (parts[index] == "practice" && parts[index + 1U] == type_id && parts[index + 2U] == lesson_id) { return type_id + "-" + lesson_id + "-" + parts[index + 3U]; } } auto stem = std::filesystem::path{source_path}.parent_path().filename().string(); if (stem.empty()) { stem = fallback; } return type_id + "-" + lesson_id + "-" + stem; } auto now_ms() -> std::int64_t { return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); } auto execute_delete_stale_catalog_rows(noto::connection& connection, std::string_view table, std::vector const& ids) -> boost::asio::awaitable> { auto sql = std::string{"DELETE FROM "}; sql += table; sql += " WHERE catalog_managed = 1"; auto params = std::vector{}; params.reserve(ids.size()); if (!ids.empty()) { sql += " AND id NOT IN ("; for (auto index = std::size_t{}; index < ids.size(); ++index) { if (index != 0U) { sql += ", "; } sql += "?"; sql += std::to_string(index + 1U); params.emplace_back(ids[index]); } sql += ")"; } auto deleted = co_await connection.async_execute(std::move(sql), std::move(params)); if (!deleted) { co_return std::unexpected(deleted.error()); } co_return std::expected{}; } } auto vamp_zero::split_migration_statements(std::string const& input) -> std::expected, modulus::error> { auto statements = std::vector{}; auto current = std::string{}; auto stream = std::istringstream{input}; auto line = std::string{}; while (std::getline(stream, line)) { if (trim(line) == statement_delimiter) { current = trim(std::move(current)); if (current.empty()) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "migration contains an empty statement")); } statements.push_back(std::move(current)); current.clear(); continue; } current += line; current.push_back('\n'); } current = trim(std::move(current)); if (!current.empty()) { statements.push_back(std::move(current)); } if (statements.empty()) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "migration must contain at least one statement")); } for (auto const& statement : statements) { if (statement.empty() || statement.back() != ';') { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "every migration statement must end with a semicolon")); } } return statements; } auto vamp_zero::async_migrate_database(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable> { if (config.path.empty() || config.migrations_path.empty()) { co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "database paths must not be empty")); } if (config.path != ":memory:") { auto parent = std::filesystem::path{config.path}.parent_path(); if (!parent.empty()) { auto error = std::error_code{}; std::filesystem::create_directories(parent, error); if (error) { co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create database directory: " + error.message())); } } } auto connection = noto::connection{executor, connection_config(config)}; auto connected = co_await connection.async_connect(); if (!connected) { co_return std::unexpected(connected.error()); } if (config.path != ":memory:") { auto journal = co_await connection.async_journal_mode("WAL"); if (!journal || *journal != "wal") { auto close = co_await connection.async_close(); if (!close) { modulus::log_error("database close failed after WAL error: " + close.error().describe()); } co_return std::unexpected(journal ? vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "SQLite did not enter WAL mode") : journal.error()); } } auto current_application_id = co_await connection.async_application_id(); if (!current_application_id) { co_return std::unexpected(current_application_id.error()); } if (*current_application_id != 0 && *current_application_id != application_id) { co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "database application id does not belong to Product Vamp")); } if (*current_application_id == 0) { auto set = co_await connection.async_set_application_id(application_id); if (!set) { co_return std::unexpected(set.error()); } } auto foundation_statements = read_migration(config, "001_foundation.sql"); if (!foundation_statements) { co_return std::unexpected(foundation_statements.error()); } auto import_statements = read_migration(config, "002_song_import_metadata.sql"); if (!import_statements) { co_return std::unexpected(import_statements.error()); } auto practice_statements = read_migration(config, "003_practice_catalog.sql"); if (!practice_statements) { co_return std::unexpected(practice_statements.error()); } auto practice_hierarchy_statements = read_migration(config, "004_practice_hierarchy.sql"); if (!practice_hierarchy_statements) { co_return std::unexpected(practice_hierarchy_statements.error()); } auto catalog_managed_statements = read_migration(config, "005_catalog_managed_rows.sql"); if (!catalog_managed_statements) { co_return std::unexpected(catalog_managed_statements.error()); } auto migrator = noto::migrator{connection}; auto migrations = std::vector{}; auto foundation = noto::migration{}; foundation.id = "001_foundation"; foundation.description = "initial Product Vamp appliance tables"; foundation.checksum = "001_foundation_v1"; foundation.up = [statements = std::move(*foundation_statements)](noto::connection& connection) -> boost::asio::awaitable> { co_return co_await execute_statements(connection, statements); }; migrations.push_back(std::move(foundation)); auto import = noto::migration{}; import.id = "002_song_import_metadata"; import.description = "song import metadata"; import.checksum = "002_song_import_metadata_v1"; import.up = [statements = std::move(*import_statements)](noto::connection& connection) -> boost::asio::awaitable> { co_return co_await execute_statements(connection, statements); }; migrations.push_back(std::move(import)); auto practice = noto::migration{}; practice.id = "003_practice_catalog"; practice.description = "practice exercise catalog"; practice.checksum = "003_practice_catalog_v1"; practice.up = [statements = std::move(*practice_statements)](noto::connection& connection) -> boost::asio::awaitable> { co_return co_await execute_statements(connection, statements); }; migrations.push_back(std::move(practice)); auto practice_hierarchy = noto::migration{}; practice_hierarchy.id = "004_practice_hierarchy"; practice_hierarchy.description = "practice type and lesson hierarchy"; practice_hierarchy.checksum = "004_practice_hierarchy_v1"; practice_hierarchy.up = [statements = std::move(*practice_hierarchy_statements)](noto::connection& connection) -> boost::asio::awaitable> { co_return co_await execute_statements(connection, statements); }; migrations.push_back(std::move(practice_hierarchy)); auto catalog_managed = noto::migration{}; catalog_managed.id = "005_catalog_managed_rows"; catalog_managed.description = "mark rows managed by bundled catalog import"; catalog_managed.checksum = "005_catalog_managed_rows_v1"; catalog_managed.up = [statements = std::move(*catalog_managed_statements)](noto::connection& connection) -> boost::asio::awaitable> { co_return co_await execute_statements(connection, statements); }; migrations.push_back(std::move(catalog_managed)); auto migrated = co_await migrator.async_migrate(std::move(migrations)); if (!migrated) { co_return std::unexpected(migrated.error()); } auto version = co_await connection.async_set_user_version(5); if (!version) { co_return std::unexpected(version.error()); } co_return co_await connection.async_close(); } auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path) -> boost::asio::awaitable> { auto performance = vamp_zero::load_performance_library(assets_path); if (!performance) { co_return std::unexpected(performance.error()); } auto practice = vamp_zero::load_practice_library(assets_path); if (!practice) { co_return std::unexpected(practice.error()); } auto connection = noto::connection{executor, connection_config(config)}; auto connected = co_await connection.async_connect(); if (!connected) { co_return std::unexpected(connected.error()); } auto begin = co_await connection.async_execute("BEGIN IMMEDIATE"); if (!begin) { co_return std::unexpected(begin.error()); } auto imported = std::expected{}; auto timestamp = now_ms(); auto song_ids = std::vector{}; song_ids.reserve(performance->parts.size()); for (auto const& part : performance->parts) { if (!imported) { break; } song_ids.push_back(part.id); auto params = std::vector{ noto::sqlite_value{part.id}, noto::sqlite_value{part.title}, noto::sqlite_value{part.composer}, noto::sqlite_value{part.source_json}, noto::sqlite_value{timestamp}, noto::sqlite_value{timestamp}, noto::sqlite_value{part.part}, noto::sqlite_value{part.source_path}, noto::sqlite_value{part.key_signature}, }; auto inserted = co_await connection.async_execute( R"sql( INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature, catalog_managed) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1) ON CONFLICT(id) DO UPDATE SET title = excluded.title, composer = excluded.composer, source_json = excluded.source_json, part = excluded.part, source_path = excluded.source_path, key_signature = excluded.key_signature, catalog_managed = 1, updated_at_ms = CASE WHEN songs.title != excluded.title OR songs.composer != excluded.composer OR songs.source_json != excluded.source_json OR songs.part != excluded.part OR songs.source_path != excluded.source_path OR songs.key_signature != excluded.key_signature THEN excluded.updated_at_ms ELSE songs.updated_at_ms END )sql", std::move(params)); if (!inserted) { imported = std::unexpected(inserted.error()); } } auto practice_ids = std::vector{}; for (auto const& exercise : practice->exercises) { if (!imported) { break; } for (auto const& variant : exercise.variants) { practice_ids.push_back(variant.id); auto params = std::vector{ noto::sqlite_value{variant.id}, noto::sqlite_value{exercise.practice_type_id}, noto::sqlite_value{exercise.practice_type_title}, noto::sqlite_value{exercise.lesson_group_id}, noto::sqlite_value{exercise.lesson_group_title}, noto::sqlite_value{exercise.title}, noto::sqlite_value{exercise.composer}, noto::sqlite_value{variant.key_signature}, noto::sqlite_value{variant.source_json}, noto::sqlite_value{variant.source_path}, noto::sqlite_value{timestamp}, noto::sqlite_value{timestamp}, }; auto inserted = co_await connection.async_execute( R"sql( INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms, catalog_managed) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1) ON CONFLICT(id) DO UPDATE SET practice_type_id = excluded.practice_type_id, practice_type_title = excluded.practice_type_title, lesson_group_id = excluded.lesson_group_id, lesson_group_title = excluded.lesson_group_title, title = excluded.title, composer = excluded.composer, key_signature = excluded.key_signature, source_json = excluded.source_json, source_path = excluded.source_path, catalog_managed = 1, updated_at_ms = CASE WHEN practice_exercises.practice_type_id != excluded.practice_type_id OR practice_exercises.practice_type_title != excluded.practice_type_title OR practice_exercises.lesson_group_id != excluded.lesson_group_id OR practice_exercises.lesson_group_title != excluded.lesson_group_title OR practice_exercises.title != excluded.title OR practice_exercises.composer != excluded.composer OR practice_exercises.key_signature != excluded.key_signature OR practice_exercises.source_json != excluded.source_json OR practice_exercises.source_path != excluded.source_path THEN excluded.updated_at_ms ELSE practice_exercises.updated_at_ms END )sql", std::move(params)); if (!inserted) { imported = std::unexpected(inserted.error()); break; } } } if (imported) { auto deleted = co_await execute_delete_stale_catalog_rows(connection, "songs", song_ids); if (!deleted) { imported = std::unexpected(deleted.error()); } } if (imported) { auto deleted = co_await execute_delete_stale_catalog_rows(connection, "practice_exercises", practice_ids); if (!deleted) { imported = std::unexpected(deleted.error()); } } if (imported) { auto committed = co_await connection.async_execute("COMMIT"); if (!committed) { imported = std::unexpected(committed.error()); } } else { static_cast(co_await connection.async_execute("ROLLBACK")); } if (!imported) { auto close = co_await connection.async_close(); if (!close) { modulus::log_error("database close failed after import error: " + close.error().describe()); } co_return std::unexpected(imported.error()); } co_return co_await connection.async_close(); } auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable> { auto connection = noto::connection{executor, connection_config(config)}; auto connected = co_await connection.async_connect(); if (!connected) { co_return std::unexpected(connected.error()); } auto selected = co_await connection.async_execute("SELECT id, title, composer, part, source_path, source_json, key_signature FROM songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE"); if (!selected) { auto close = co_await connection.async_close(); if (!close) { modulus::log_error("database close failed after load error: " + close.error().describe()); } co_return std::unexpected(selected.error()); } auto parts = std::vector{}; for (auto const& row : selected->rows) { if (row.size() < 7U) { continue; } auto source_json = text_value(row[5]); auto parsed = vamp_zero::parse_score_document(source_json, text_value(row[4])); if (!parsed) { auto close = co_await connection.async_close(); if (!close) { modulus::log_error("database close failed after parse error: " + close.error().describe()); } co_return std::unexpected(parsed.error()); } parts.push_back({ .id = text_value(row[0]), .title = text_value(row[1]), .composer = text_value(row[2]), .key_signature = text_value(row[6]), .part = text_value(row[3]), .source_path = text_value(row[4]), .source_json = std::move(source_json), .measure_count = parsed->measures.size(), .total_pages = vamp_zero::score_page_count(*parsed), }); } auto closed = co_await connection.async_close(); if (!closed) { co_return std::unexpected(closed.error()); } co_return vamp_zero::group_library_parts(std::move(parts)); } auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable> { auto connection = noto::connection{executor, connection_config(config)}; auto connected = co_await connection.async_connect(); if (!connected) { co_return std::unexpected(connected.error()); } auto selected = co_await connection.async_execute("SELECT id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, key_signature, source_path, source_json FROM practice_exercises ORDER BY practice_type_id COLLATE NOCASE, lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE, title COLLATE NOCASE, id COLLATE NOCASE"); if (!selected) { auto close = co_await connection.async_close(); if (!close) { modulus::log_error("database close failed after practice load error: " + close.error().describe()); } co_return std::unexpected(selected.error()); } auto exercises = std::vector{}; for (auto const& row : selected->rows) { if (row.size() < 10U) { continue; } auto source_json = text_value(row[9]); auto parsed = vamp_zero::parse_score_document(source_json, text_value(row[8])); if (!parsed) { auto close = co_await connection.async_close(); if (!close) { modulus::log_error("database close failed after practice parse error: " + close.error().describe()); } co_return std::unexpected(parsed.error()); } auto type_id = text_value(row[1]); auto lesson_id = text_value(row[3]); auto source_path = text_value(row[8]); auto key_signature = text_value(row[7]).empty() ? std::string{"C"} : text_value(row[7]); auto variant = vamp_zero::practice_exercise::score_variant{ .id = text_value(row[0]), .key_signature = key_signature, .source_path = source_path, .source_json = source_json, .measure_count = parsed->measures.size(), .total_pages = vamp_zero::score_page_count(*parsed), }; exercises.push_back({ .id = logical_practice_exercise_id(type_id, lesson_id, source_path, text_value(row[5])), .practice_type_id = std::move(type_id), .practice_type_title = text_value(row[2]), .lesson_group_id = std::move(lesson_id), .lesson_group_title = text_value(row[4]), .title = text_value(row[5]), .composer = text_value(row[6]), .key_signature = variant.key_signature, .source_path = variant.source_path, .source_json = variant.source_json, .measure_count = variant.measure_count, .total_pages = variant.total_pages, .variants = {std::move(variant)}, }); } auto closed = co_await connection.async_close(); if (!closed) { co_return std::unexpected(closed.error()); } co_return vamp_zero::group_practice_exercises(std::move(exercises)); }