yams/tests/unit/metadata/database_catch2_test.cpp
Trevon 3f37e8f564
Sync experimental into main: Search retrieval improvements (#60)
* fix(graph): handle qualified C++ symbols

Signed-off-by: trvon <me@trevon.dev>

* refactor(vector)!: remove dead transition APIs

Split Simeon vector and retrieval linkage so consumers inherit only the runtime surface they use.

BREAKING CHANGE: remove unused async/model/config helpers and the undefined vector-backend factory from public headers.

Signed-off-by: trvon <me@trevon.dev>

* fix(vector): harden PQ snapshot lifecycle

Validate and atomically install persisted generations, revalidate after races, and fall back to exact search when a snapshot is stale. Add the exact-scan reference lane and distinguish observed work counters from unavailable backend metrics.

Signed-off-by: trvon <me@trevon.dev>

* feat(topology): expose route coordinates

Persist chart distortion and local-dimension evidence, retain pre-scalarization route axes, and emit detailed trace rows without treating unavailable vector work as zero.

Signed-off-by: trvon <me@trevon.dev>

* feat(xplan): add vector decision gates

Track binary and corpus identity, preserve missing-work semantics, and add repeated PQ routing, crossover, and rerank plans with quality and cost summaries.

Signed-off-by: trvon <me@trevon.dev>

* feat(topology): formalize coordinate routing

Separate chart geometry from route-risk admission and prove candidate-stage recall and strict-focus boundaries. Record the measured atlas limitations and literature basis for the next construction experiment.

Signed-off-by: trvon <me@trevon.dev>

* fix(search): serialize SymSpell readers

SQLite-backed lookups reuse mutable prepared statements, so shared-reader locking is unsafe. Advance the owned SymSpell revision for batched delete insertion and cover concurrent root lookups under TSAN.

Signed-off-by: trvon <me@trevon.dev>

* fix(vector): make mutations atomic

Preserve stored vectors on replacement and commit failures, reject cross-thread writes during explicit transactions, and restore exact reranking for quantized-primary PQ rows.

* fix(search)!: surface SymSpell write errors

Propagate persistent dictionary failures through metadata ingestion, roll back failed batches, and make clear remove stored terms.

BREAKING CHANGE: SymSpellSearch mutations now return Result values.

* feat(topology): formalize candidate expansion

* feat(vector): add coordinate-aware embeddings

* feat(topology): preserve protected relation cover

* feat(search): add graph candidate rescue

* feat(daemon): expose retrieval hydration telemetry

* feat(xplan): add rescue and hydration gates

* refactor(content): dedupe media handlers

Reuse shared graph presenter helpers and centralize repeated audio/video/archive buffer processing, validation, lifecycle, and stats helper logic.

* feat(lifecycle): prune stale corpus data

Keep cleanup preview-first while retention stays opt-in and long prune work no longer holds IPC responses open.

* feat(search): add topology candidate rescue

Preserve high-value routed candidates across vector backends while keeping exact rerank diagnostics explicit.

* feat(search): expose evidence paths

* docs: define mobile host contract

* formal: model bounded candidate rescue

* fix(prune): inspect extensionless signatures

* fix(prune): tolerate stale extensions

---------

Signed-off-by: trvon <me@trevon.dev>
2026-07-24 19:00:22 -04:00

706 lines
24 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright 2025 YAMS Contributors
#include <chrono>
#include <filesystem>
#include <thread>
#include <catch2/catch_test_macros.hpp>
#include <yams/metadata/connection_pool.h>
#include <yams/metadata/database.h>
#include <yams/metadata/migration.h>
#include <yams/storage/sqlite_retry.h>
using namespace yams;
using namespace yams::metadata;
namespace {
struct DatabaseFixture {
DatabaseFixture() {
const char* t = std::getenv("YAMS_TEST_TMPDIR");
auto base = (t && *t) ? std::filesystem::path(t) : std::filesystem::temp_directory_path();
std::error_code ec;
std::filesystem::create_directories(base, ec);
auto ts = std::chrono::steady_clock::now().time_since_epoch().count();
dbPath_ = base / (std::string("database_catch2_test_") + std::to_string(ts) + ".db");
std::filesystem::remove(dbPath_, ec);
}
~DatabaseFixture() {
std::error_code ec;
std::filesystem::remove(dbPath_, ec);
}
std::filesystem::path dbPath_;
};
} // namespace
TEST_CASE("Database: open and close", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
REQUIRE_FALSE(db.isOpen());
SECTION("Open creates database file") {
auto result = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(result.has_value());
REQUIRE(db.isOpen());
SECTION("Close marks database as closed") {
db.close();
CHECK_FALSE(db.isOpen());
}
}
}
TEST_CASE("Database: verify FTS5 support", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
auto fts5Result = db.hasFTS5();
REQUIRE(fts5Result.has_value());
CHECK(fts5Result.value()); // "FTS5 support is required"
}
TEST_CASE("Database: create table", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
auto result = db.execute(R"(
CREATE TABLE test_table (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
value REAL
)
)");
REQUIRE(result.has_value());
auto exists = db.tableExists("test_table");
REQUIRE(exists.has_value());
CHECK(exists.value());
}
TEST_CASE("Database: prepared statements", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
// Create table
db.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)");
SECTION("Insert and query data") {
auto insertStmtResult = db.prepare("INSERT INTO test (name, value) VALUES (?, ?)");
REQUIRE(insertStmtResult.has_value());
Statement insertStmt = std::move(insertStmtResult).value();
auto bindResult = insertStmt.bindAll("Test1", 42);
REQUIRE(bindResult.has_value());
auto execResult = insertStmt.execute();
REQUIRE(execResult.has_value());
CHECK((db.lastInsertRowId() == 1));
CHECK((db.changes() == 1));
SECTION("Query the inserted data") {
auto selectStmtResult = db.prepare("SELECT * FROM test WHERE id = ?");
REQUIRE(selectStmtResult.has_value());
Statement selectStmt = std::move(selectStmtResult).value();
selectStmt.bind(1, 1);
auto stepResult = selectStmt.step();
REQUIRE(stepResult.has_value());
CHECK(stepResult.value());
CHECK((selectStmt.getInt(0) == 1));
CHECK((selectStmt.getString(1) == "Test1"));
CHECK((selectStmt.getInt(2) == 42));
}
}
SECTION("bindAll normalizes long long integers through the SQLite int64 path") {
auto insertStmtResult = db.prepare("INSERT INTO test (id, name, value) VALUES (?, ?, ?)");
REQUIRE(insertStmtResult.has_value());
Statement insertStmt = std::move(insertStmtResult).value();
const long long rowId = 922337203685477000LL;
const long long value = 922337203685477111LL;
auto bindResult = insertStmt.bindAll(rowId, "LongLong", value);
REQUIRE(bindResult.has_value());
REQUIRE(insertStmt.execute().has_value());
auto selectStmtResult = db.prepare("SELECT id, name, value FROM test WHERE id = ?");
REQUIRE(selectStmtResult.has_value());
Statement selectStmt = std::move(selectStmtResult).value();
REQUIRE(selectStmt.bindAll(rowId).has_value());
auto stepResult = selectStmt.step();
REQUIRE(stepResult.has_value());
REQUIRE(stepResult.value());
CHECK((selectStmt.getInt64(0) == static_cast<std::int64_t>(rowId)));
CHECK((selectStmt.getString(1) == "LongLong"));
CHECK((selectStmt.getInt64(2) == static_cast<std::int64_t>(value)));
}
}
TEST_CASE("Database: statement failures include sqlite error detail",
"[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
auto ftsResult = db.execute("CREATE VIRTUAL TABLE docs_fts USING fts5(content)");
if (!ftsResult.has_value()) {
SKIP("FTS5 unavailable in this environment");
}
SECTION("step failure surfaces sqlite3_errmsg detail") {
auto stmtResult = db.prepare("SELECT rowid FROM docs_fts WHERE docs_fts MATCH ?");
REQUIRE(stmtResult.has_value());
Statement stmt = std::move(stmtResult).value();
REQUIRE(stmt.bind(1, std::string("NoSuchColumn:value")).has_value());
auto stepResult = stmt.step();
REQUIRE_FALSE(stepResult.has_value());
CHECK(
(stepResult.error().message.find("no such column: NoSuchColumn") != std::string::npos));
}
SECTION("execute failure surfaces sqlite3_errmsg detail") {
auto stmtResult = db.prepare("SELECT rowid FROM docs_fts WHERE docs_fts MATCH ?");
REQUIRE(stmtResult.has_value());
Statement stmt = std::move(stmtResult).value();
REQUIRE(stmt.bind(1, std::string("NoSuchColumn:value")).has_value());
auto execResult = stmt.execute();
REQUIRE_FALSE(execResult.has_value());
CHECK(
(execResult.error().message.find("no such column: NoSuchColumn") != std::string::npos));
}
}
TEST_CASE("Database: transactions", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
db.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, value INTEGER)");
SECTION("Successful transaction") {
auto beginResult = db.beginTransaction();
REQUIRE(beginResult.has_value());
db.execute("INSERT INTO test (value) VALUES (1)");
db.execute("INSERT INTO test (value) VALUES (2)");
auto commitResult = db.commit();
REQUIRE(commitResult.has_value());
auto stmtResult = db.prepare("SELECT COUNT(*) FROM test");
REQUIRE(stmtResult.has_value());
Statement stmt = std::move(stmtResult).value();
stmt.step();
CHECK((stmt.getInt(0) == 2));
}
SECTION("Rollback transaction") {
// Insert baseline data
db.execute("INSERT INTO test (value) VALUES (1)");
db.execute("INSERT INTO test (value) VALUES (2)");
auto beginResult = db.beginTransaction();
REQUIRE(beginResult.has_value());
db.execute("INSERT INTO test (value) VALUES (3)");
auto rollbackResult = db.rollback();
REQUIRE(rollbackResult.has_value());
auto stmtResult = db.prepare("SELECT COUNT(*) FROM test");
REQUIRE(stmtResult.has_value());
Statement stmt = std::move(stmtResult).value();
stmt.step();
CHECK((stmt.getInt(0) == 2)); // Still 2, not 3
}
}
TEST_CASE("Database: open enables WAL automatically for writable modes",
"[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
auto stmtResult = db.prepare("PRAGMA journal_mode");
REQUIRE(stmtResult.has_value());
Statement stmt = std::move(stmtResult).value();
auto stepResult = stmt.step();
REQUIRE(stepResult.has_value());
REQUIRE(stepResult.value());
CHECK((stmt.getString(0) == "wal"));
}
TEST_CASE("Database: WAL mode", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
auto walResult = db.enableWAL();
REQUIRE(walResult.has_value());
// Verify WAL mode is enabled
auto stmtResult = db.prepare("PRAGMA journal_mode");
REQUIRE(stmtResult.has_value());
Statement stmt = std::move(stmtResult).value();
auto stepResult = stmt.step();
REQUIRE(stepResult.has_value());
CHECK(stepResult.value());
CHECK((stmt.getString(0) == "wal"));
}
TEST_CASE("Database: QueryBuilder", "[unit][metadata][database]") {
QueryBuilder qb;
SECTION("SELECT query") {
auto sql = qb.select({"id", "name", "value"})
.from("users")
.where("active = 1")
.andWhere("age > ?")
.orderBy("name", true)
.limit(10)
.build();
CHECK((sql == "SELECT id, name, value FROM users WHERE active = 1 AND age > ? ORDER BY "
"name ASC LIMIT 10"));
}
SECTION("INSERT query") {
qb.reset();
auto sql = qb.insertInto("users").values({"name", "email", "age"}).build();
CHECK((sql == "INSERT INTO users (name, email, age) VALUES (?, ?, ?)"));
}
SECTION("UPDATE query") {
qb.reset();
auto sql = qb.update("users")
.set("name", "?")
.set("updated_at", "strftime('%s', 'now')")
.where("id = ?")
.build();
CHECK(
(sql == "UPDATE users SET name = ?, updated_at = strftime('%s', 'now') WHERE id = ?"));
}
}
TEST_CASE("Database: ConnectionPool", "[unit][metadata][database]") {
DatabaseFixture fix;
ConnectionPoolConfig config;
config.minConnections = 2;
config.maxConnections = 5;
config.enableWAL = true;
ConnectionPool pool(fix.dbPath_.string(), config);
auto initResult = pool.initialize();
REQUIRE(initResult.has_value());
SECTION("Initial stats") {
auto stats = pool.getStats();
CHECK((stats.totalConnections == 2));
CHECK((stats.availableConnections == 2));
CHECK((stats.activeConnections == 0));
}
SECTION("Acquire and release connection") {
{
auto connResult = pool.acquire();
REQUIRE(connResult.has_value());
auto conn = std::move(connResult).value();
REQUIRE(conn->isValid());
// Use connection
auto result = (*conn)->execute("CREATE TABLE test (id INTEGER)");
REQUIRE(result.has_value());
auto stats = pool.getStats();
CHECK((stats.activeConnections == 1));
// Note: availableConnections may be >= 1 due to background maintenance thread
CHECK((stats.availableConnections >= 1));
}
// Connection returned to pool
auto stats = pool.getStats();
CHECK((stats.activeConnections == 0));
// Note: availableConnections may be >= 2 due to background maintenance thread
CHECK((stats.availableConnections >= 2));
}
pool.shutdown();
}
TEST_CASE("Database: Migrations", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
MigrationManager mm(db);
auto initResult = mm.initialize();
REQUIRE(initResult.has_value());
// Register migrations
mm.registerMigrations(YamsMetadataMigrations::getAllMigrations());
SECTION("Check current version before migration") {
auto currentVersion = mm.getCurrentVersion();
REQUIRE(currentVersion.has_value());
CHECK((currentVersion.value() == 0));
}
SECTION("Check if migration needed") {
auto needsMigration = mm.needsMigration();
REQUIRE(needsMigration.has_value());
CHECK(needsMigration.value());
}
SECTION("Apply migrations") {
auto migrateResult = mm.migrate();
REQUIRE(migrateResult.has_value());
const int latestAvailable = mm.getLatestVersion();
auto currentVersion = mm.getCurrentVersion();
REQUIRE(currentVersion.has_value());
CHECK((currentVersion.value() == latestAvailable));
// Verify tables exist
auto docExists = db.tableExists("documents");
REQUIRE(docExists.has_value());
CHECK(docExists.value());
auto metaExists = db.tableExists("metadata");
REQUIRE(metaExists.has_value());
CHECK(metaExists.value());
// Verify FTS table exists if FTS5 is available
auto fts5Available = db.hasFTS5();
if (fts5Available && fts5Available.value()) {
auto ftsExists = db.tableExists("documents_fts");
REQUIRE(ftsExists.has_value());
CHECK(ftsExists.value());
}
}
}
TEST_CASE("Database: failed migration reports error", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
MigrationManager mm(db);
REQUIRE(mm.initialize().has_value());
Migration broken;
broken.version = 1;
broken.name = "broken_migration";
broken.upSQL = "INVALID SQL THAT WILL FAIL";
broken.wrapInTransaction = true;
mm.registerMigration(broken);
auto needsBefore = mm.needsMigration();
REQUIRE(needsBefore.has_value());
CHECK(needsBefore.value());
auto result = mm.migrate();
CHECK_FALSE(result.has_value());
auto needsAfter = mm.needsMigration();
REQUIRE(needsAfter.has_value());
CHECK(needsAfter.value());
auto version = mm.getCurrentVersion();
REQUIRE(version.has_value());
CHECK((version.value() == 0));
}
TEST_CASE("Database: migration rollback restores previous version", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
MigrationManager mm(db);
REQUIRE(mm.initialize().has_value());
Migration good;
good.version = 1;
good.name = "create_test_table";
good.upSQL = "CREATE TABLE rollback_test (id INTEGER PRIMARY KEY, name TEXT)";
good.downSQL = "DROP TABLE IF EXISTS rollback_test";
mm.registerMigration(good);
REQUIRE(mm.migrate().has_value());
CHECK(db.tableExists("rollback_test").value());
REQUIRE(mm.rollbackTo(0).has_value());
CHECK_FALSE(db.tableExists("rollback_test").value());
}
TEST_CASE("Database: Concurrent access", "[unit][metadata][database][.slow]") {
DatabaseFixture fix;
ConnectionPool pool(fix.dbPath_.string());
auto initResult = pool.initialize();
REQUIRE(initResult.has_value());
// Create table
pool.withConnection([](Database& db) {
return db.execute("CREATE TABLE counter (id INTEGER PRIMARY KEY, value INTEGER)");
});
pool.withConnection(
[](Database& db) { return db.execute("INSERT INTO counter (id, value) VALUES (1, 0)"); });
const int numThreads = 10;
const int incrementsPerThread = 100;
std::vector<std::thread> threads;
for (int i = 0; i < numThreads; ++i) {
threads.emplace_back([&pool]() {
for (int j = 0; j < incrementsPerThread; ++j) {
pool.withConnection([](Database& db) -> Result<void> {
return db.transaction([&db]() -> Result<void> {
auto stmtResult =
db.prepare("UPDATE counter SET value = value + 1 WHERE id = 1");
if (!stmtResult)
return stmtResult.error();
Statement stmt = std::move(stmtResult).value();
return stmt.execute();
});
});
}
});
}
for (auto& t : threads) {
t.join();
}
// Check final value
auto result = pool.withConnection([](Database& db) -> Result<int> {
auto stmtResult = db.prepare("SELECT value FROM counter WHERE id = 1");
if (!stmtResult)
return stmtResult.error();
Statement stmt = std::move(stmtResult).value();
auto stepResult = stmt.step();
if (!stepResult)
return stepResult.error();
return stmt.getInt(0);
});
REQUIRE(result.has_value());
CHECK((result.value() == numThreads * incrementsPerThread));
}
TEST_CASE("Database: Statement cache", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
// Create a test table
auto createResult = db.execute("CREATE TABLE test_cache (id INTEGER PRIMARY KEY, value TEXT)");
REQUIRE(createResult.has_value());
SECTION("prepareCached returns working statement") {
auto stmtResult = db.prepareCached("INSERT INTO test_cache (id, value) VALUES (?, ?)");
REQUIRE(stmtResult.has_value());
auto& stmt = *stmtResult.value();
auto bindResult = stmt.bindAll(1, "hello");
REQUIRE(bindResult.has_value());
auto execResult = stmt.execute();
REQUIRE(execResult.has_value());
}
SECTION("prepareCached reuses statements") {
const std::string sql = "SELECT id, value FROM test_cache WHERE id = ?";
// First call - cache miss
{
auto stmtResult = db.prepareCached(sql);
REQUIRE(stmtResult.has_value());
} // Statement returned to cache
auto stats1 = db.getStatementCacheStats();
CHECK((stats1.misses == 1));
CHECK((stats1.hits == 0));
CHECK((stats1.currentSize == 1));
// Second call - cache hit
{
auto stmtResult = db.prepareCached(sql);
REQUIRE(stmtResult.has_value());
}
auto stats2 = db.getStatementCacheStats();
CHECK((stats2.misses == 1));
CHECK((stats2.hits == 1));
CHECK((stats2.currentSize == 1));
// Third call - another cache hit
{
auto stmtResult = db.prepareCached(sql);
REQUIRE(stmtResult.has_value());
}
auto stats3 = db.getStatementCacheStats();
CHECK((stats3.hits == 2));
}
SECTION("clearStatementCache clears cache and stats") {
auto stmtResult = db.prepareCached("SELECT 1");
REQUIRE(stmtResult.has_value());
stmtResult.value().release(); // Don't return to cache
// Use it again to populate cache
stmtResult = db.prepareCached("SELECT 1");
REQUIRE(stmtResult.has_value());
db.clearStatementCache();
auto stats = db.getStatementCacheStats();
CHECK((stats.hits == 0));
CHECK((stats.misses == 0));
CHECK((stats.currentSize == 0));
}
SECTION("CachedStatement can be released") {
auto stmtResult = db.prepareCached("SELECT 1");
REQUIRE(stmtResult.has_value());
Statement stmt = stmtResult.value().release();
auto stepResult = stmt.step();
REQUIRE(stepResult.has_value());
CHECK((stepResult.value() == true));
CHECK((stmt.getInt(0) == 1));
// Statement won't be returned to cache since it was released
}
}
TEST_CASE("Database: operations after close return errors", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
REQUIRE(db.execute("CREATE TABLE post_close(id INTEGER PRIMARY KEY)").has_value());
db.close();
CHECK_FALSE(db.isOpen());
auto execAfter = db.execute("CREATE TABLE should_fail(id INTEGER PRIMARY KEY)");
CHECK_FALSE(execAfter.has_value());
auto prepareAfter = db.prepare("SELECT 1");
CHECK_FALSE(prepareAfter.has_value());
auto tableAfter = db.tableExists("post_close");
CHECK_FALSE(tableAfter.has_value());
}
TEST_CASE("Database: open nonexistent file without Create mode fails",
"[unit][metadata][database]") {
DatabaseFixture fix;
auto nonexistent =
fix.dbPath_.parent_path() /
("nonexistent_" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".db");
Database db;
auto result = db.open(nonexistent.string(), ConnectionMode::ReadWrite);
CHECK_FALSE(result.has_value());
CHECK_FALSE(db.isOpen());
}
TEST_CASE("Database: close clears statement cache", "[unit][metadata][database]") {
DatabaseFixture fix;
Database db;
auto openResult = db.open(fix.dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult.has_value());
REQUIRE(db.execute("CREATE TABLE cache_clear(id INTEGER PRIMARY KEY, val TEXT)").has_value());
{
auto stmt = db.prepareCached("INSERT INTO cache_clear VALUES (?, ?)");
REQUIRE(stmt.has_value());
stmt.value()->bind(1, 1);
stmt.value()->bind(2, std::string_view{"a"});
REQUIRE(stmt.value()->execute().has_value());
}
auto stats = db.getStatementCacheStats();
CHECK((stats.currentSize > 0));
db.close();
auto statsAfter = db.getStatementCacheStats();
CHECK((statsAfter.currentSize == 0));
}
TEST_CASE("Database: FTS5 integrity errors are not transient lock errors",
"[unit][metadata][database][fts5]") {
// Reproduces the issue: when quick_check reports FTS5 inverted-index
// validation errors that happen to contain the word "locked", the
// isBusyOrLockedMessage heuristic falsely classifies them as transient
// SQLite contention. The daemon then calls ensureDatabaseIntegrityOrRecover
// which sees ResourceBusy, closes the DB, returns false, and the FSM
// stays stuck in OpeningDatabase.
//
// These are real FTS5 index corruption errors that require `yams repair
// --fts5`, not a transient lock that would resolve on retry.
// Simulate the exact message the user sees in logs:
// "unable to validate the inverted index for FTS5 table main.documents_fts:
// database is locked"
const std::string fts5LockedMsg =
"unable to validate the inverted index for FTS5 table main.documents_fts: "
"database is locked";
// NOTE: Because the message contains "locked", isBusyOrLockedMessage returns
// true (this is the root of the false-classification bug).
REQUIRE(yams::storage::sqlite_retry::isBusyOrLockedMessage(fts5LockedMsg));
// After the fix: the FTS5 + "inverted index" combination must cause
// is_transient_integrity_check_message to return false — the error is
// persistent FTS5 index corruption, not transient SQLite contention.
REQUIRE_FALSE(yams::metadata::testing_isTransientIntegrityCheckMessage(fts5LockedMsg));
// Non-FTS5 transient lock messages should still be classified as transient.
const std::string plainLockedMsg = "database table is locked";
REQUIRE(yams::metadata::testing_isTransientIntegrityCheckMessage(plainLockedMsg));
const std::string busyMsg = "database is busy";
REQUIRE(yams::metadata::testing_isTransientIntegrityCheckMessage(busyMsg));
}