Styles
ci / checks (push) Successful in 39m31s

This commit is contained in:
khodowany
2026-09-08 22:07:31 -05:00
parent 6e7db6fd97
commit fa7485d404
28 changed files with 522 additions and 292 deletions
+1 -7
View File
@@ -43,7 +43,6 @@ auto strip_cr(std::string_view line) -> std::string_view {
auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remote_display_state& state) -> std::bool_t {
auto parsed = vamp_pico::remote_display_state{};
auto saw_version = std::bool_t{};
auto cursor = std::size_t{};
while (cursor <= body.size()) {
auto line_end = body.find('\n', cursor);
@@ -53,9 +52,7 @@ auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remo
if (equals != std::string_view::npos) {
auto key = line.substr(0U, equals);
auto value = line.substr(equals + 1U);
if (key == "version") {
saw_version = true;
} else if (key == "active") {
if (key == "active") {
parsed.active = value == "1";
} else if (key == "title") {
copy_value(value, parsed.title, sizeof(parsed.title));
@@ -78,9 +75,6 @@ auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remo
}
cursor = line_end + 1U;
}
if (!saw_version) {
return false;
}
state = parsed;
return true;
}
+11 -11
View File
@@ -41,7 +41,7 @@ auto invalid(std::string message) -> modulus::error {
}
auto usage() -> std::string {
return "usage: vamp-zero [--config PATH] [--check-config] [--health-check] [--version] [--once] [--migrate]";
return "usage: vamp-zero [--config PATH] [--check-config] [--health-check] [--version] [--once] [--ensure-database]";
}
auto bind_port(vamp_zero::config const& config) -> std::uint16_t {
@@ -88,9 +88,9 @@ auto write_heartbeat(vamp_zero::config const& config) -> std::expected<void, mod
return write_text_atomic(config.runtime.heartbeat_path, boost::json::serialize(heartbeat));
}
auto migrate(vamp_zero::config const& config) -> std::expected<void, modulus::error> {
auto ensure_database(vamp_zero::config const& config) -> std::expected<void, modulus::error> {
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, vamp_zero::async_migrate_database(io.get_executor(), config.database), boost::asio::use_future);
auto future = boost::asio::co_spawn(io, vamp_zero::async_ensure_database(io.get_executor(), config.database), boost::asio::use_future);
io.run();
return future.get();
}
@@ -258,8 +258,8 @@ auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t** argv) -> std
result.print_version = true;
} else if (argument == "--once") {
result.exit_after_startup = true;
} else if (argument == "--migrate") {
result.migrate = true;
} else if (argument == "--ensure-database") {
result.ensure_database = true;
} else if (argument == "--help" || argument == "-h") {
return std::unexpected(invalid(usage()));
} else {
@@ -270,7 +270,7 @@ auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t** argv) -> std
+ static_cast<std::uint32_t>(result.health_check ? 1 : 0)
+ static_cast<std::uint32_t>(result.print_version ? 1 : 0)
+ static_cast<std::uint32_t>(result.exit_after_startup ? 1 : 0)
+ static_cast<std::uint32_t>(result.migrate ? 1 : 0);
+ static_cast<std::uint32_t>(result.ensure_database ? 1 : 0);
if (command_count > 1U) {
return std::unexpected(invalid("only one command flag may be selected"));
}
@@ -295,17 +295,17 @@ auto vamp_zero::run(vamp_zero::command_line command) -> std::expected<void, modu
std::cout << "ok\n";
return {};
}
if (command.migrate) {
return migrate(*config);
if (command.ensure_database) {
return ensure_database(*config);
}
if (command.exit_after_startup || config->runtime.exit_after_startup) {
std::cout << "ok\n";
return {};
}
auto migrated = migrate(*config);
if (!migrated) {
return migrated;
auto ensured = ensure_database(*config);
if (!ensured) {
return ensured;
}
auto imported = import_library(*config);
if (!imported) {
+2 -4
View File
@@ -116,13 +116,11 @@ auto parse_database(boost::json::object const& root, vamp_zero::database_config
return fallback;
}
auto path = optional_string(**object, "path", fallback.path);
auto migrations_path = optional_string(**object, "migrations_path", fallback.migrations_path);
if (!path || !migrations_path) {
return std::unexpected(!path ? path.error() : migrations_path.error());
if (!path) {
return std::unexpected(path.error());
}
return vamp_zero::database_config{
.path = std::move(*path),
.migrations_path = std::move(*migrations_path),
};
}
+164 -149
View File
@@ -5,9 +5,6 @@
#include <cstdint>
#include <expected>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
@@ -26,38 +23,6 @@
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<std::string, modulus::error> {
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<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
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<std::string> statements) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
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<void, modulus::error>{};
}
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");
@@ -70,19 +35,165 @@ auto connection_config(vamp_zero::database_config const& config) -> noto::connec
return result;
}
auto read_migration(vamp_zero::database_config const& config, std::string filename) -> std::expected<std::vector<std::string>, 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<std::string>(&value.value);
return text == nullptr ? std::string{} : *text;
}
auto execute_schema_statement(noto::connection& connection, std::string statement) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto result = co_await connection.async_execute(std::move(statement));
if (!result) {
co_return std::unexpected(result.error());
}
co_return std::expected<void, modulus::error>{};
}
auto column_exists(noto::connection& connection, std::string table, std::string column) -> boost::asio::awaitable<std::expected<std::bool_t, modulus::error>> {
auto selected = co_await connection.async_execute("PRAGMA table_info(" + table + ")");
if (!selected) {
co_return std::unexpected(selected.error());
}
for (auto const& row : selected->rows) {
if (row.size() >= 2U && text_value(row[1]) == column) {
co_return true;
}
}
co_return false;
}
auto ensure_column(noto::connection& connection, std::string table, std::string column, std::string alter_statement) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto exists = co_await column_exists(connection, std::move(table), std::move(column));
if (!exists) {
co_return std::unexpected(exists.error());
}
if (*exists) {
co_return std::expected<void, modulus::error>{};
}
co_return co_await execute_schema_statement(connection, std::move(alter_statement));
}
auto ensure_current_schema(noto::connection& connection) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto begin = co_await connection.async_execute("BEGIN IMMEDIATE");
if (!begin) {
co_return std::unexpected(begin.error());
}
auto ensured = std::expected<void, modulus::error>{};
auto schema_statements = std::vector<std::string>{
R"sql(
CREATE TABLE IF NOT EXISTS songs (
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120),
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
composer TEXT NOT NULL DEFAULT '',
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms),
part TEXT NOT NULL DEFAULT '',
source_path TEXT NOT NULL DEFAULT '',
key_signature TEXT NOT NULL DEFAULT '',
catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))
) STRICT
)sql",
R"sql(
CREATE TABLE IF NOT EXISTS page_state (
id INTEGER PRIMARY KEY CHECK(id = 1),
active_song_id TEXT NOT NULL DEFAULT '',
page_index INTEGER NOT NULL DEFAULT 0 CHECK(page_index >= 0),
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= 0)
) STRICT
)sql",
R"sql(
CREATE TABLE IF NOT EXISTS practice_exercises (
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160),
practice_type_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(practice_type_id)) BETWEEN 1 AND 120),
practice_type_title TEXT NOT NULL DEFAULT 'General Practice' CHECK(length(trim(practice_type_title)) BETWEEN 1 AND 200),
lesson_group_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(lesson_group_id)) BETWEEN 1 AND 120),
lesson_group_title TEXT NOT NULL DEFAULT 'General' CHECK(length(trim(lesson_group_title)) BETWEEN 1 AND 200),
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
composer TEXT NOT NULL DEFAULT '',
key_signature TEXT NOT NULL DEFAULT 'C',
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
source_path TEXT NOT NULL DEFAULT '',
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms),
catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))
) STRICT
)sql",
"DROP TABLE IF EXISTS _noto_schema_migrations",
};
for (auto& statement : schema_statements) {
if (!ensured) {
break;
}
ensured = co_await execute_schema_statement(connection, std::move(statement));
}
if (ensured) {
ensured = co_await ensure_column(connection, "songs", "part", "ALTER TABLE songs ADD COLUMN part TEXT NOT NULL DEFAULT ''");
}
if (ensured) {
ensured = co_await ensure_column(connection, "songs", "source_path", "ALTER TABLE songs ADD COLUMN source_path TEXT NOT NULL DEFAULT ''");
}
if (ensured) {
ensured = co_await ensure_column(connection, "songs", "key_signature", "ALTER TABLE songs ADD COLUMN key_signature TEXT NOT NULL DEFAULT ''");
}
if (ensured) {
ensured = co_await ensure_column(connection, "songs", "catalog_managed", "ALTER TABLE songs ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))");
}
if (ensured) {
ensured = co_await ensure_column(connection, "practice_exercises", "practice_type_id", "ALTER TABLE practice_exercises ADD COLUMN practice_type_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(practice_type_id)) BETWEEN 1 AND 120)");
}
if (ensured) {
ensured = co_await ensure_column(connection, "practice_exercises", "practice_type_title", "ALTER TABLE practice_exercises ADD COLUMN practice_type_title TEXT NOT NULL DEFAULT 'General Practice' CHECK(length(trim(practice_type_title)) BETWEEN 1 AND 200)");
}
if (ensured) {
ensured = co_await ensure_column(connection, "practice_exercises", "lesson_group_id", "ALTER TABLE practice_exercises ADD COLUMN lesson_group_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(lesson_group_id)) BETWEEN 1 AND 120)");
}
if (ensured) {
ensured = co_await ensure_column(connection, "practice_exercises", "lesson_group_title", "ALTER TABLE practice_exercises ADD COLUMN lesson_group_title TEXT NOT NULL DEFAULT 'General' CHECK(length(trim(lesson_group_title)) BETWEEN 1 AND 200)");
}
if (ensured) {
ensured = co_await ensure_column(connection, "practice_exercises", "source_path", "ALTER TABLE practice_exercises ADD COLUMN source_path TEXT NOT NULL DEFAULT ''");
}
if (ensured) {
ensured = co_await ensure_column(connection, "practice_exercises", "catalog_managed", "ALTER TABLE practice_exercises ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))");
}
auto index_statements = std::vector<std::string>{
"CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_songs_title_part ON songs(title COLLATE NOCASE, part COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_songs_catalog_managed ON songs(catalog_managed)",
"CREATE INDEX IF NOT EXISTS idx_practice_exercises_title ON practice_exercises(title COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_practice_exercises_hierarchy ON practice_exercises(practice_type_id COLLATE NOCASE, lesson_group_id COLLATE NOCASE, title COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_practice_exercises_catalog_managed ON practice_exercises(catalog_managed)",
};
for (auto& statement : index_statements) {
if (!ensured) {
break;
}
ensured = co_await execute_schema_statement(connection, std::move(statement));
}
if (ensured) {
auto committed = co_await connection.async_execute("COMMIT");
if (!committed) {
ensured = std::unexpected(committed.error());
}
} else {
static_cast<void>(co_await connection.async_execute("ROLLBACK"));
}
if (!ensured) {
co_return std::unexpected(ensured.error());
}
auto version = co_await connection.async_set_user_version(0);
if (!version) {
co_return std::unexpected(version.error());
}
co_return std::expected<void, modulus::error>{};
}
auto path_parts(std::filesystem::path const& path) -> std::vector<std::string> {
auto parts = std::vector<std::string>{};
for (auto const& part : path) {
@@ -136,42 +247,9 @@ auto execute_delete_stale_catalog_rows(noto::connection& connection, std::string
}
auto vamp_zero::split_migration_statements(std::string const& input) -> std::expected<std::vector<std::string>, modulus::error> {
auto statements = std::vector<std::string>{};
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<std::expected<void, modulus::error>> {
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"));
auto vamp_zero::async_ensure_database(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
if (config.path.empty()) {
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "database path must not be empty"));
}
if (config.path != ":memory:") {
auto parent = std::filesystem::path{config.path}.parent_path();
@@ -213,76 +291,13 @@ auto vamp_zero::async_migrate_database(boost::asio::any_io_executor executor, va
}
}
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<noto::migration>{};
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<std::expected<void, modulus::error>> {
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<std::expected<void, modulus::error>> {
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<std::expected<void, modulus::error>> {
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<std::expected<void, modulus::error>> {
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<std::expected<void, modulus::error>> {
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());
auto ensured = co_await ensure_current_schema(connection);
if (!ensured) {
auto close = co_await connection.async_close();
if (!close) {
modulus::log_error("database close failed after schema ensure error: " + close.error().describe());
}
co_return std::unexpected(ensured.error());
}
co_return co_await connection.async_close();
}
-1
View File
@@ -554,7 +554,6 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const& state) -> std::s
}
auto output = std::ostringstream{};
output << "version=1\n";
output << "mode=" << vamp_zero::to_string(state.mode) << "\n";
output << "active=" << (active ? "1" : "0") << "\n";
output << "title=" << sanitize_line_value(title) << "\n";