See More

#include "sqlite/backend_impl.h" #include "embedder.h" #include "config.h" #include "lang.h" #include "util/fs.h" #include "util/time.h" #include "util/sqlite.h" #include "double_metaphone.h" #include "fts_tokenize.h" #include "sqlite/text_index.h" #include "vector_codec.h" #include "Logger.h" #include #include "nlohmann_json.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace ragger::sqlite { using json = nlohmann::json; namespace fs = std::filesystem; /// Users + settings tables — declarative, no in-place migration /// (single-user app; pre-v2 data is exported out-of-band). `users` mirrors /// the reference DDL plus `password_hash` for credentialed access. /// Shared by both constructors. void Backend::Impl::create_user_schema() { exec(R"( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL, password_hash TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ) )"); exec(R"( CREATE TRIGGER IF NOT EXISTS users_modified AFTER UPDATE ON users BEGIN UPDATE users SET updated_at = unixepoch() WHERE id = NEW.id; END )"); exec(R"( CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) )"); } // ---- User / settings CRUD (gap closure) -------------------------------- std::optional Backend::Impl::get_user_by_username(const std::string& username) { Stmt s(db, "SELECT id, username, token_hash FROM users WHERE username = ?"); s.bind(1, username); if (s.step()) return UserInfo{s.column_int(0), s.column_text(1), s.column_text(2)}; return std::nullopt; } std::optional Backend::Impl::get_user_by_token_hash(const std::string& token_hash) { Stmt s(db, "SELECT id, username, token_hash FROM users WHERE token_hash = ?"); s.bind(1, token_hash); if (s.step()) return UserInfo{s.column_int(0), s.column_text(1), s.column_text(2)}; return std::nullopt; } std::optional<:string> Backend::Impl::get_user_password(const std::string& username) { Stmt s(db, "SELECT password_hash FROM users WHERE username = ?"); s.bind(1, username); if (s.step()) return s.column_text_opt(0); return std::nullopt; } void Backend::Impl::set_user_password(const std::string& username, const std::string& pw_hash) { Stmt s(db, "UPDATE users SET password_hash = ? WHERE username = ?"); s.bind(1, pw_hash).bind(2, username).step(); } int Backend::Impl::create_user(const std::string& username, const std::string& token_hash) { int64_t ts = db_epoch(); Stmt s(db, "INSERT INTO users (username, token_hash, created_at, updated_at) VALUES (?,?,?,?)"); s.bind(1, username).bind(2, token_hash).bind(3, ts).bind(4, ts).step(); return sqlite3_changes(db) > 0 ? static_cast(sqlite3_last_insert_rowid(db)) : -1; } bool Backend::Impl::delete_user(const std::string& username) { Stmt s(db, "DELETE FROM users WHERE username = ?"); s.bind(1, username).step(); return sqlite3_changes(db) > 0; } void Backend::Impl::update_user_token(const std::string& username, const std::string& new_hash) { Stmt s(db, "UPDATE users SET token_hash = ? WHERE username = ?"); s.bind(1, new_hash).bind(2, username).step(); } std::optional<:string> Backend::Impl::get_setting(const std::string& key) { Stmt s(db, "SELECT value FROM settings WHERE key = ?"); s.bind(1, key); if (s.step()) return s.column_text_opt(0); return std::nullopt; } void Backend::Impl::set_setting(const std::string& key, const std::string& value) { Stmt s(db, "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)"); s.bind(1, key).bind(2, value).step(); } // ---- Schema introspection (gap closure) -------------------------------- std::vector Backend::Impl::list_schema_objects() { std::vector result; Stmt s(db, "SELECT type, name, tbl_name, sql FROM sqlite_master " "WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' " "ORDER BY CASE type " " WHEN 'table' THEN 0 " " WHEN 'index' THEN 1 " " WHEN 'trigger' THEN 2 " " ELSE 3 END, name"); while (s.step_checked()) { SchemaObject obj; obj.type = s.column_text(0); obj.name = s.column_text(1); obj.tbl_name = s.column_text(2); obj.sql = s.column_text(3); // Filter FTS5 shadow tables and other internal tables if (obj.name.find("_fts") != std::string::npos) continue; result.push_back(std::move(obj)); } return result; } std::vector<:string> Backend::Impl::table_column_names(const std::string& table) { std::vector<:string> names; Stmt s(db, "PRAGMA table_info(" + table + ")"); while (s.step_checked()) { names.push_back(s.column_text(1)); // col 1 = column name } return names; } int Backend::Impl::iterate_table_rows(const std::string& table, const std::function& cb) { Stmt s(db, "SELECT * FROM " + table); int count = 0; while (s.step_checked()) { int ncols = sqlite3_column_count(s.raw()); ExportRow row(ncols); for (int i = 0; i < ncols; ++i) { int coltype = sqlite3_column_type(s.raw(), i); switch (coltype) { case SQLITE_NULL: row[i].type = ExportCell::Type::Null; break; case SQLITE_INTEGER: row[i].type = ExportCell::Type::Integer; row[i].int_val = sqlite3_column_int64(s.raw(), i); break; case SQLITE_FLOAT: row[i].type = ExportCell::Type::Float; row[i].float_val = sqlite3_column_double(s.raw(), i); break; case SQLITE_BLOB: { row[i].type = ExportCell::Type::Blob; const auto* p = static_cast(sqlite3_column_blob(s.raw(), i)); int nb = sqlite3_column_bytes(s.raw(), i); row[i].blob_val.assign(p, p + nb); break; } case SQLITE_TEXT: default: { row[i].type = ExportCell::Type::Text; const char* txt = reinterpret_cast( sqlite3_column_text(s.raw(), i)); row[i].text_val = txt ? txt : ""; break; } } } cb(row); ++count; } return count; } /// Convenience views: epoch ints rendered as local datetime, embedding /// BLOBs collapsed to a has_embedding flag, and term_ids resolved to the /// readable term. These exist purely for human inspection (sqlite3 CLI, /// GUI browsers) -- no application code reads them. /// /// DROP-then-CREATE, deliberately NOT "CREATE VIEW IF NOT EXISTS". A view /// holds no data, so recreating is free -- and IF NOT EXISTS meant an /// existing DB kept a stale view definition FOREVER after this DDL changed /// (exactly how the v0.16 views were left still selecting the retired /// `phon` column). Unconditional recreation keeps every DB's views in /// lockstep with this code on every open. void Backend::Impl::create_views() { // Column order mirrors the base tables: narrow, readable columns first, // wide/derived ones last, so `SELECT *` stays legible on one screen. auto view = [&](const std::string& name, const std::string& body) { exec(std::format("DROP VIEW IF EXISTS {}", name)); exec(std::format("CREATE VIEW {} AS {}", name, body)); }; view("users_view", R"( SELECT id, username, token_hash, password_hash, datetime(created_at, 'unixepoch', 'localtime') AS created_at, datetime(updated_at, 'unixepoch', 'localtime') AS updated_at FROM users )"); view("models_view", R"( SELECT model_id, name, datetime(created_at, 'unixepoch', 'localtime') AS created_at FROM models )"); view("sessions_view", R"( SELECT session_id, guid, name, name_source, datetime(started_on, 'unixepoch', 'localtime') AS started_on, datetime(ended_on, 'unixepoch', 'localtime') AS ended_on, datetime(created_at, 'unixepoch', 'localtime') AS created_at FROM sessions )"); view("turns_view", R"( SELECT turn_id, user_text, assistant_text, model_id, session_id, datetime(created_at, 'unixepoch', 'localtime') AS created_at, unigram_count, bigram_count, embedding_version, CASE WHEN embedding IS NULL THEN 0 ELSE 1 END AS has_embedding FROM turns )"); // PHASE5: gains episode_id. The view shows ALL rows including '' // marker rows (the raw table is the admin truth); Phase 9 decides // whether a filtered `_live_view` joins the set. view("turn_summaries_view", R"( SELECT turn_summary_id, text, turn_id, session_id, episode_id, turn_model_id, summary_model_id, datetime(turn_datetime, 'unixepoch', 'localtime') AS turn_datetime, datetime(summarized_on, 'unixepoch', 'localtime') AS summarized_on, unigram_count, bigram_count, embedding_version, CASE WHEN embedding IS NULL THEN 0 ELSE 1 END AS has_embedding FROM turn_summaries )"); view("episodes_view", R"( SELECT episode_id, text, tags, session_id, model_id, supersedes, datetime(span_start, 'unixepoch', 'localtime') AS span_start, datetime(span_end, 'unixepoch', 'localtime') AS span_end, datetime(created_at, 'unixepoch', 'localtime') AS created_at, unigram_count, bigram_count, embedding_version, CASE WHEN embedding IS NULL THEN 0 ELSE 1 END AS has_embedding FROM episodes )"); // the old summaries view is gone with its table (PHASE8); knowledge_view // covers notes now. // knowledge_view (PHASE7): replaces decisions_view + insights_view. // No *_evidence_view: the old evidence tables never had one (same // convention kept for knowledge_turn_evidence / _document_evidence). view("knowledge_view", R"( SELECT knowledge_id, kind, text, status, tags, trigger, rationale, scope, supersedes, session_id, model_id, datetime(created_at, 'unixepoch', 'localtime') AS created_at, unigram_count, bigram_count, embedding_version, CASE WHEN embedding IS NULL THEN 0 ELSE 1 END AS has_embedding FROM knowledge )"); view("document_sources_view", R"( SELECT document_source_id, title, path, year, tags, datetime(imported_at, 'unixepoch', 'localtime') AS imported_at FROM document_sources )"); view("documents_view", R"( SELECT document_id, text, tags, chunk_index, document_source_id, datetime(modified_on, 'unixepoch', 'localtime') AS modified_on, unigram_count, bigram_count, embedding_version, CASE WHEN embedding IS NULL THEN 0 ELSE 1 END AS has_embedding FROM documents )"); // Junction views (v0.16): the raw _terms tables store term_id, which // is unreadable on inspection. Each view resolves it to the actual term // string. An INNER JOIN is correct here -- the FK to terms(term_id) is // NOT NULL, so a missing parent would be corruption, and surfacing zero // rows for it is the right signal. Ordered most-frequent-first within a // record, which is what you almost always want when eyeballing why // something did or didn't match. Driven by the registry (PHASE3 Step 3). for (const auto& t : kTextTables) { view(std::string(t.junction) + "_view", std::format( "SELECT j.{0} AS {0}, t.term, j.count " "FROM {1} j JOIN terms t ON t.term_id = j.term_id " "ORDER BY j.{0}, j.count DESC, t.term", t.pk, t.junction)); } } } // namespace ragger::sqlite