From 80578ff163925afbdc7e75ffff7a626b4a552d17 Mon Sep 17 00:00:00 2001 From: Marco Bambini Date: Fri, 17 Apr 2026 15:22:25 +0200 Subject: [PATCH 1/2] Converted INTEGER primary key to TEXT --- README.md | 2 + src/dbmem-search.c | 24 +- src/sqlite-memory.c | 589 ++++++++++++++++++++++++++-- src/sqlite-memory.h | 2 +- test/e2e.c | 13 +- test/unittest.c | 911 +++++++++++++++++++++++++++++++++++++++----- 6 files changed, 1399 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index 2bf607c..21fda0f 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,8 @@ This makes all sync functions safe to call repeatedly - for example, on a cron s Multiple agents can share and merge knowledge without any coordination. Each agent works independently with its own local SQLite database, syncing through a shared [SQLiteCloud](https://sqlitecloud.io/) managed database when connectivity is available. +> Upgrade note: if sync was enabled with a sqlite-memory version earlier than `1.0.0`, you must set it up again from scratch after upgrading. Version `1.0.0` changed the internal table declarations used by sync, so existing pre-`1.0.0` synced databases are not compatible with the new schema. + Enable sync on a database connection before ingesting content: ```sql diff --git a/src/dbmem-search.c b/src/dbmem-search.c index 9b4f6e4..3ff755a 100644 --- a/src/dbmem-search.c +++ b/src/dbmem-search.c @@ -15,6 +15,7 @@ #include "sqlite3.h" #endif +#include #include #include #include @@ -266,10 +267,12 @@ static void vMemorySearchUpdateAccess(sqlite3 *db, vMemorySearchCursor *c) { if (rc != SQLITE_OK) return; sqlite3_int64 now = (sqlite3_int64)time(NULL); + char hash_hex[17]; for (int i = 0; i < c->count; i++) { + snprintf(hash_hex, sizeof(hash_hex), "%016llx", (unsigned long long)(uint64_t)c->hash[i]); sqlite3_bind_int64(vm, 1, now); - sqlite3_bind_int64(vm, 2, c->hash[i]); + sqlite3_bind_text(vm, 2, hash_hex, -1, SQLITE_STATIC); sqlite3_step(vm); sqlite3_reset(vm); } @@ -346,8 +349,9 @@ static int dbmem_fts_search (sqlite3 *db, vMemorySearchCursor *c, const char *in if (rank < rank_min) rank_min = rank; if (rank > rank_max) rank_max = rank; + const char *hash_text = (const char *)sqlite3_column_text(vm, 1); c->fts.rank[count] = rank; - c->fts.hash[count] = sqlite3_column_int64(vm, 1); + c->fts.hash[count] = (sqlite3_int64)(uint64_t)strtoull(hash_text ? hash_text : "0", NULL, 16); c->fts.seq[count] = sqlite3_column_int64(vm, 2); c->fts.count++; @@ -408,8 +412,9 @@ static int dbmem_semantic_search (sqlite3 *db, vMemorySearchCursor *c, float *em else if (rc != SQLITE_ROW) break; // SQLITE_ROW + const char *hash_text = (const char *)sqlite3_column_text(vm, 1); c->semantic.rank[count] = sqlite3_column_double(vm, 0); - c->semantic.hash[count] = sqlite3_column_int64(vm, 1); + c->semantic.hash[count] = (sqlite3_int64)(uint64_t)strtoull(hash_text ? hash_text : "0", NULL, 16); c->semantic.seq[count] = sqlite3_column_int64(vm, 2); c->semantic.count++; @@ -528,25 +533,28 @@ static int vMemorySearchCursorColumn (sqlite3_vtab_cursor *cur, sqlite3_context vMemorySearchCursor *c = (vMemorySearchCursor *)cur; sqlite3 *db = ((vMemorySearchTable *)cur->pVtab)->db; + char hash_hex[17]; + snprintf(hash_hex, sizeof(hash_hex), "%016llx", (unsigned long long)(uint64_t)c->hash[c->index]); + switch (iCol) { case SEARCH_COLUMN_HASH: - sqlite3_result_int64(context, c->hash[c->index]); + sqlite3_result_text(context, hash_hex, 16, SQLITE_TRANSIENT); break; - + case SEARCH_COLUMN_SEQ: sqlite3_result_int64(context, c->seq[c->index]); break; - + case SEARCH_COLUMN_RANKING: sqlite3_result_double(context, c->rank[c->index]); break; - + case SEARCH_COLUMN_PATH: case SEARCH_COLUMN_SNIPPET:{ const char *sql = (iCol == SEARCH_COLUMN_PATH) ? path_sql : snippet_sql; sqlite3_stmt *vm = NULL; if (sqlite3_prepare_v2(db, sql, -1, &vm, NULL) == SQLITE_OK) { - sqlite3_bind_int64(vm, 1, c->hash[c->index]); + sqlite3_bind_text(vm, 1, hash_hex, -1, SQLITE_STATIC); if (iCol == SEARCH_COLUMN_SNIPPET) sqlite3_bind_int64(vm, 2, c->seq[c->index]); if (sqlite3_step(vm) == SQLITE_ROW) sqlite3_result_value(context, sqlite3_column_value(vm, 0)); } diff --git a/src/sqlite-memory.c b/src/sqlite-memory.c index 2c7a0c7..a33a1f6 100644 --- a/src/sqlite-memory.c +++ b/src/sqlite-memory.c @@ -331,32 +331,512 @@ void dbmem_settings_load (sqlite3 *db, dbmem_context *ctx) { return; } +// MARK: - Hash helpers - + +// Convert uint64 hash to 16-char lowercase hex string (buf must be at least 17 bytes) +static void dbmem_hash_to_hex (uint64_t hash, char buf[17]) { + snprintf(buf, 17, "%016llx", (unsigned long long)hash); +} + +typedef struct { + char **sql; + int count; +} dbmem_schema_object_list; + +typedef struct { + bool enabled; + dbmem_schema_object_list shadow_objects; + dbmem_schema_object_list triggers; +} dbmem_cloudsync_state; + +static void dbmem_schema_object_list_reset (dbmem_schema_object_list *list); +static int dbmem_schema_object_list_load_query (sqlite3 *db, const char *sql, const char *param, dbmem_schema_object_list *list); + +static void dbmem_cloudsync_state_reset (dbmem_cloudsync_state *state) { + if (!state) return; + dbmem_schema_object_list_reset(&state->shadow_objects); + dbmem_schema_object_list_reset(&state->triggers); + state->enabled = false; +} + +static void dbmem_schema_object_list_reset (dbmem_schema_object_list *list) { + if (!list) return; + for (int i = 0; i < list->count; i++) sqlite3_free(list->sql[i]); + sqlite3_free(list->sql); + list->sql = NULL; + list->count = 0; +} + +static int dbmem_schema_object_list_load (sqlite3 *db, const char *table_name, dbmem_schema_object_list *list) { + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT sql FROM sqlite_master " + "WHERE tbl_name=?1 AND type IN ('index', 'trigger') AND sql IS NOT NULL " + "ORDER BY CASE type WHEN 'index' THEN 0 ELSE 1 END, name;", + -1, &vm, NULL); + if (rc != SQLITE_OK) return rc; + + rc = sqlite3_bind_text(vm, 1, table_name, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) { + sqlite3_finalize(vm); + return rc; + } + + while ((rc = sqlite3_step(vm)) == SQLITE_ROW) { + const char *sql = (const char *)sqlite3_column_text(vm, 0); + char **new_sql = (char **)sqlite3_realloc64(list->sql, sizeof(char *) * (size_t)(list->count + 1)); + if (!new_sql) { + sqlite3_finalize(vm); + return SQLITE_NOMEM; + } + list->sql = new_sql; + list->sql[list->count] = sqlite3_mprintf("%s", sql ? sql : ""); + if (!list->sql[list->count]) { + sqlite3_finalize(vm); + return SQLITE_NOMEM; + } + list->count++; + } + + sqlite3_finalize(vm); + return (rc == SQLITE_DONE) ? SQLITE_OK : rc; +} + +static int dbmem_schema_object_list_load_without_cloudsync (sqlite3 *db, const char *table_name, dbmem_schema_object_list *list) { + return dbmem_schema_object_list_load_query(db, + "SELECT sql FROM sqlite_master " + "WHERE tbl_name=?1 AND sql IS NOT NULL AND (" + "type='index' OR (type='trigger' AND name NOT LIKE 'cloudsync_%')) " + "ORDER BY CASE type WHEN 'index' THEN 0 ELSE 1 END, name;", + table_name, list); +} + +static int dbmem_schema_object_list_apply (sqlite3 *db, dbmem_schema_object_list *list) { + for (int i = 0; i < list->count; i++) { + int rc = sqlite3_exec(db, list->sql[i], NULL, NULL, NULL); + if (rc != SQLITE_OK) return rc; + } + return SQLITE_OK; +} + +static int dbmem_pragma_get_int (sqlite3 *db, const char *pragma_sql, int *value) { + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(db, pragma_sql, -1, &vm, NULL); + if (rc != SQLITE_OK) return rc; + rc = sqlite3_step(vm); + if (rc == SQLITE_ROW) { + *value = sqlite3_column_int(vm, 0); + rc = SQLITE_OK; + } + sqlite3_finalize(vm); + return rc; +} + +static int dbmem_schema_object_list_load_query (sqlite3 *db, const char *sql, const char *param, dbmem_schema_object_list *list) { + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + if (rc != SQLITE_OK) return rc; + + if (param) { + rc = sqlite3_bind_text(vm, 1, param, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) { + sqlite3_finalize(vm); + return rc; + } + } + + while ((rc = sqlite3_step(vm)) == SQLITE_ROW) { + const char *obj_sql = (const char *)sqlite3_column_text(vm, 0); + char **new_sql = (char **)sqlite3_realloc64(list->sql, sizeof(char *) * (size_t)(list->count + 1)); + if (!new_sql) { + sqlite3_finalize(vm); + return SQLITE_NOMEM; + } + list->sql = new_sql; + list->sql[list->count] = sqlite3_mprintf("%s", obj_sql ? obj_sql : ""); + if (!list->sql[list->count]) { + sqlite3_finalize(vm); + return SQLITE_NOMEM; + } + list->count++; + } + + sqlite3_finalize(vm); + return (rc == SQLITE_DONE) ? SQLITE_OK : rc; +} + +static int dbmem_schema_load_cloudsync_state (sqlite3 *db, dbmem_cloudsync_state *state) { + sqlite3_stmt *vm = NULL; + bool has_table = false; + int rc = sqlite3_prepare_v2(db, + "SELECT 1 FROM sqlite_master " + "WHERE type='table' AND name='cloudsync_table_settings' LIMIT 1;", + -1, &vm, NULL); + if (rc != SQLITE_OK) return rc; + + if (sqlite3_step(vm) == SQLITE_ROW) has_table = true; + sqlite3_finalize(vm); + vm = NULL; + + if (!has_table) return SQLITE_OK; + + rc = sqlite3_prepare_v2(db, + "SELECT value FROM cloudsync_table_settings " + "WHERE tbl_name='dbmem_content' AND col_name='*' AND key='algo' " + "LIMIT 1;", + -1, &vm, NULL); + if (rc != SQLITE_OK) return rc; + + if (sqlite3_step(vm) == SQLITE_ROW) state->enabled = true; + sqlite3_finalize(vm); + vm = NULL; + + if (!state->enabled) return SQLITE_OK; + + rc = dbmem_schema_object_list_load_query(db, + "SELECT sql FROM sqlite_master " + "WHERE name LIKE 'dbmem_content_cloudsync%' AND type IN ('table', 'index') AND sql IS NOT NULL " + "ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name;", + NULL, &state->shadow_objects); + if (rc != SQLITE_OK) return rc; + + return dbmem_schema_object_list_load_query(db, + "SELECT sql FROM sqlite_master " + "WHERE tbl_name=?1 AND type='trigger' AND name LIKE 'cloudsync_%' AND sql IS NOT NULL " + "ORDER BY name;", + "dbmem_content", &state->triggers); +} + +static bool dbmem_fts5_available (sqlite3 *db) { + int rc = sqlite3_exec(db, + "CREATE VIRTUAL TABLE temp._dbmem_fts5_probe USING fts5(x);", + NULL, NULL, NULL); + sqlite3_exec(db, "DROP TABLE IF EXISTS temp._dbmem_fts5_probe;", NULL, NULL, NULL); + return (rc == SQLITE_OK); +} + +static bool dbmem_fts_table_needs_hash_migration (sqlite3 *db) { + sqlite3_stmt *vm = NULL; + bool needs = false; + int rc = sqlite3_prepare_v2(db, + "SELECT 1 " + "FROM dbmem_vault_fts AS f " + "LEFT JOIN dbmem_vault AS v_direct " + " ON f.hash = v_direct.hash AND f.seq = v_direct.seq " + "JOIN dbmem_vault AS v_migrated " + " ON printf('%016x', CAST(f.hash AS INTEGER)) = v_migrated.hash " + " AND f.seq = v_migrated.seq " + "WHERE v_direct.hash IS NULL " + "LIMIT 1;", + -1, &vm, NULL); + if (rc != SQLITE_OK) return false; + if (sqlite3_step(vm) == SQLITE_ROW) needs = true; + sqlite3_finalize(vm); + return needs; +} + +static int dbmem_fts_migrate_hashes (sqlite3 *db) { + int rc = sqlite3_exec(db, + "CREATE TEMP TABLE _fts_backup AS " + "SELECT content, hash, seq, context FROM dbmem_vault_fts;", + NULL, NULL, NULL); + if (rc != SQLITE_OK) return rc; + + rc = sqlite3_exec(db, "DROP TABLE dbmem_vault_fts;", NULL, NULL, NULL); + if (rc != SQLITE_OK) { + sqlite3_exec(db, "DROP TABLE IF EXISTS _fts_backup;", NULL, NULL, NULL); + return rc; + } + + rc = sqlite3_exec(db, + "CREATE VIRTUAL TABLE dbmem_vault_fts USING fts5 " + "(content, hash UNINDEXED, seq UNINDEXED, context UNINDEXED);", + NULL, NULL, NULL); + if (rc != SQLITE_OK) { + sqlite3_exec(db, "DROP TABLE IF EXISTS _fts_backup;", NULL, NULL, NULL); + return rc; + } + + rc = sqlite3_exec(db, + "INSERT INTO dbmem_vault_fts (content, hash, seq, context) " + "SELECT content, printf('%016x', CAST(hash AS INTEGER)), seq, context " + "FROM _fts_backup;", + NULL, NULL, NULL); + sqlite3_exec(db, "DROP TABLE IF EXISTS _fts_backup;", NULL, NULL, NULL); + return rc; +} + // MARK: - Database - +// Check if the existing schema uses INTEGER for the hash column (pre-1.0.0 schema) +static bool dbmem_schema_needs_migration (sqlite3 *db) { + static const char *sql = "SELECT type FROM pragma_table_info('dbmem_content') WHERE name='hash' LIMIT 1;"; + sqlite3_stmt *vm = NULL; + bool needs = false; + if (sqlite3_prepare_v2(db, sql, -1, &vm, NULL) != SQLITE_OK) return false; + if (sqlite3_step(vm) == SQLITE_ROW) { + const char *type = (const char *)sqlite3_column_text(vm, 0); + if (type && strcasecmp(type, "INTEGER") == 0) needs = true; + } + sqlite3_finalize(vm); + return needs; +} + +// Migrate schema from INTEGER hash (pre-1.0.0) to TEXT hash (1.0.0+) +// Hashes are converted to 16-char lowercase hex strings. +static int dbmem_schema_migrate (sqlite3 *db) { + int rc = SQLITE_OK; + bool in_savepoint = false; + int old_foreign_keys = 0; + int old_legacy_alter_table = 0; + bool altered_pragmas = false; + dbmem_cloudsync_state sync_state = {0}; + dbmem_schema_object_list content_objects = {0}; + dbmem_schema_object_list vault_objects = {0}; + dbmem_schema_object_list cache_objects = {0}; + sqlite3_stmt *vm = NULL; + bool has_cache = false; + bool has_fts = false; + + rc = dbmem_schema_load_cloudsync_state(db, &sync_state); + if (rc != SQLITE_OK) goto cleanup; + + if (sync_state.enabled) { + // Refuse to migrate a synced database unless sqlite-sync is loaded on this + // connection, otherwise the rebuilt table would silently stop syncing. + rc = sqlite3_exec(db, "SELECT cloudsync_version();", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto cleanup; + } + + rc = dbmem_pragma_get_int(db, "PRAGMA foreign_keys;", &old_foreign_keys); + if (rc != SQLITE_OK) goto cleanup; + rc = dbmem_pragma_get_int(db, "PRAGMA legacy_alter_table;", &old_legacy_alter_table); + if (rc != SQLITE_OK) goto cleanup; + rc = sqlite3_exec(db, "PRAGMA foreign_keys=OFF;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto cleanup; + rc = sqlite3_exec(db, "PRAGMA legacy_alter_table=ON;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto cleanup; + altered_pragmas = true; + + rc = sqlite3_exec(db, "SAVEPOINT dbmem_schema_migrate;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto cleanup; + in_savepoint = true; + + if (sync_state.enabled) rc = dbmem_schema_object_list_load_without_cloudsync(db, "dbmem_content", &content_objects); + else rc = dbmem_schema_object_list_load(db, "dbmem_content", &content_objects); + if (rc != SQLITE_OK) goto rollback; + + // --- dbmem_content --- + rc = sqlite3_exec(db, "ALTER TABLE dbmem_content RENAME TO _dbmem_content_old;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, + "CREATE TABLE dbmem_content (" + "hash TEXT PRIMARY KEY NOT NULL, " + "path TEXT NOT NULL DEFAULT '' UNIQUE, " + "value TEXT DEFAULT NULL, " + "length INTEGER NOT NULL DEFAULT 0, " + "context TEXT DEFAULT NULL, " + "created_at INTEGER DEFAULT 0, " + "last_accessed INTEGER DEFAULT 0);", + NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, + "INSERT INTO dbmem_content " + "SELECT printf('%016x', hash), path, value, length, context, created_at, last_accessed " + "FROM _dbmem_content_old;", + NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, "DROP TABLE _dbmem_content_old;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = dbmem_schema_object_list_load(db, "dbmem_vault", &vault_objects); + if (rc != SQLITE_OK) goto rollback; + + // --- dbmem_vault --- + rc = sqlite3_exec(db, "ALTER TABLE dbmem_vault RENAME TO _dbmem_vault_old;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, + "CREATE TABLE dbmem_vault (" + "hash TEXT NOT NULL, " + "seq INTEGER NOT NULL, " + "embedding BLOB NOT NULL, " + "offset INTEGER NOT NULL, " + "length INTEGER NOT NULL, " + "PRIMARY KEY (hash, seq));", + NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, + "INSERT INTO dbmem_vault " + "SELECT printf('%016x', hash), seq, embedding, offset, length " + "FROM _dbmem_vault_old;", + NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, "DROP TABLE _dbmem_vault_old;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + // --- dbmem_cache (may not exist in very old databases) --- + if (sqlite3_prepare_v2(db, + "SELECT name FROM sqlite_master WHERE type='table' AND name='dbmem_cache' LIMIT 1;", + -1, &vm, NULL) == SQLITE_OK) { + if (sqlite3_step(vm) == SQLITE_ROW) has_cache = true; + sqlite3_finalize(vm); + vm = NULL; + } else { + rc = sqlite3_errcode(db); + goto rollback; + } + + if (has_cache) { + rc = dbmem_schema_object_list_load(db, "dbmem_cache", &cache_objects); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, "ALTER TABLE dbmem_cache RENAME TO _dbmem_cache_old;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, + "CREATE TABLE dbmem_cache (" + "text_hash TEXT NOT NULL, " + "provider TEXT NOT NULL, " + "model TEXT NOT NULL, " + "embedding BLOB NOT NULL, " + "dimension INTEGER NOT NULL, " + "PRIMARY KEY (text_hash, provider, model));", + NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, + "INSERT INTO dbmem_cache " + "SELECT printf('%016x', text_hash), provider, model, embedding, dimension " + "FROM _dbmem_cache_old;", + NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, "DROP TABLE _dbmem_cache_old;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + } + + // --- dbmem_vault_fts (FTS5 virtual table — must drop+recreate) --- + if (sqlite3_prepare_v2(db, + "SELECT name FROM sqlite_master WHERE type='table' AND name='dbmem_vault_fts' LIMIT 1;", + -1, &vm, NULL) == SQLITE_OK) { + if (sqlite3_step(vm) == SQLITE_ROW) has_fts = true; + sqlite3_finalize(vm); + vm = NULL; + } else { + rc = sqlite3_errcode(db); + goto rollback; + } + + if (has_fts) { + // P2: probe FTS5 availability BEFORE reading from the old virtual table. + // Accessing dbmem_vault_fts requires the FTS5 module; if the module is absent + // on this build we must not SELECT from the table. + if (!dbmem_fts5_available(db)) { + // Leave the old FTS table untouched so a later FTS-enabled build can + // repair its hashes in-place without losing the indexed content. + } else { + rc = dbmem_fts_migrate_hashes(db); + if (rc != SQLITE_OK) goto rollback; + } + } + + if (sync_state.enabled) { + rc = sqlite3_exec(db, "DROP TABLE IF EXISTS dbmem_content_cloudsync_blocks;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_exec(db, "DROP TABLE IF EXISTS dbmem_content_cloudsync;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = dbmem_schema_object_list_apply(db, &sync_state.shadow_objects); + if (rc != SQLITE_OK) goto rollback; + + rc = dbmem_schema_object_list_apply(db, &sync_state.triggers); + if (rc != SQLITE_OK) goto rollback; + } + + rc = dbmem_schema_object_list_apply(db, &content_objects); + if (rc != SQLITE_OK) goto rollback; + + rc = dbmem_schema_object_list_apply(db, &vault_objects); + if (rc != SQLITE_OK) goto rollback; + + rc = dbmem_schema_object_list_apply(db, &cache_objects); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, "RELEASE SAVEPOINT dbmem_schema_migrate;", NULL, NULL, NULL); + if (rc == SQLITE_OK) in_savepoint = false; + goto cleanup; + +rollback: + if (in_savepoint) { + sqlite3_exec(db, "ROLLBACK TO SAVEPOINT dbmem_schema_migrate;", NULL, NULL, NULL); + sqlite3_exec(db, "RELEASE SAVEPOINT dbmem_schema_migrate;", NULL, NULL, NULL); + in_savepoint = false; + } + +cleanup: + if (altered_pragmas) { + char *sql = sqlite3_mprintf("PRAGMA legacy_alter_table=%d;", old_legacy_alter_table ? 1 : 0); + if (sql) { + sqlite3_exec(db, sql, NULL, NULL, NULL); + sqlite3_free(sql); + } + sql = sqlite3_mprintf("PRAGMA foreign_keys=%d;", old_foreign_keys ? 1 : 0); + if (sql) { + sqlite3_exec(db, sql, NULL, NULL, NULL); + sqlite3_free(sql); + } + } + if (vm) sqlite3_finalize(vm); + dbmem_cloudsync_state_reset(&sync_state); + dbmem_schema_object_list_reset(&content_objects); + dbmem_schema_object_list_reset(&vault_objects); + dbmem_schema_object_list_reset(&cache_objects); + return rc; +} + static int dbmem_database_init (sqlite3 *db) { + // Create settings table first (always safe, no migration needed for it) const char *sql = "CREATE TABLE IF NOT EXISTS dbmem_settings (key TEXT PRIMARY KEY, value TEXT);"; int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) return rc; - - sql = "CREATE TABLE IF NOT EXISTS dbmem_content (hash INTEGER PRIMARY KEY NOT NULL, path TEXT NOT NULL DEFAULT '' UNIQUE, value TEXT DEFAULT NULL, length INTEGER NOT NULL DEFAULT 0, context TEXT DEFAULT NULL, created_at INTEGER DEFAULT 0, last_accessed INTEGER DEFAULT 0);"; + + // Migrate from pre-1.0.0 schema (INTEGER hash → TEXT hash) if needed + if (dbmem_schema_needs_migration(db)) { + rc = dbmem_schema_migrate(db); + if (rc != SQLITE_OK) return rc; + } + + sql = "CREATE TABLE IF NOT EXISTS dbmem_content (hash TEXT PRIMARY KEY NOT NULL, path TEXT NOT NULL DEFAULT '' UNIQUE, value TEXT DEFAULT NULL, length INTEGER NOT NULL DEFAULT 0, context TEXT DEFAULT NULL, created_at INTEGER DEFAULT 0, last_accessed INTEGER DEFAULT 0);"; rc = sqlite3_exec(db, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) return rc; - - sql = "CREATE TABLE IF NOT EXISTS dbmem_vault (hash INTEGER NOT NULL, seq INTEGER NOT NULL, embedding BLOB NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, PRIMARY KEY (hash, seq));"; + + sql = "CREATE TABLE IF NOT EXISTS dbmem_vault (hash TEXT NOT NULL, seq INTEGER NOT NULL, embedding BLOB NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, PRIMARY KEY (hash, seq));"; rc = sqlite3_exec(db, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) return rc; - - sql = "CREATE TABLE IF NOT EXISTS dbmem_cache (text_hash INTEGER NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, embedding BLOB NOT NULL, dimension INTEGER NOT NULL, PRIMARY KEY (text_hash, provider, model));"; + + sql = "CREATE TABLE IF NOT EXISTS dbmem_cache (text_hash TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, embedding BLOB NOT NULL, dimension INTEGER NOT NULL, PRIMARY KEY (text_hash, provider, model));"; rc = sqlite3_exec(db, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) return rc; - sql = "CREATE VIRTUAL TABLE IF NOT EXISTS dbmem_vault_fts USING fts5 (content, hash UNINDEXED, seq UNINDEXED, context UNINDEXED);"; - rc = sqlite3_exec(db, sql, NULL, NULL, NULL); - if (rc != SQLITE_OK) { - fts5_is_available = false; - rc = SQLITE_OK; + fts5_is_available = dbmem_fts5_available(db); + if (fts5_is_available) { + sql = "CREATE VIRTUAL TABLE IF NOT EXISTS dbmem_vault_fts USING fts5 (content, hash UNINDEXED, seq UNINDEXED, context UNINDEXED);"; + rc = sqlite3_exec(db, sql, NULL, NULL, NULL); + if (rc != SQLITE_OK) { + fts5_is_available = false; + rc = SQLITE_OK; + } else if (dbmem_fts_table_needs_hash_migration(db)) { + rc = dbmem_fts_migrate_hashes(db); + if (rc != SQLITE_OK) return rc; + } } - + // explicitly allows extension loading (only available when linked statically) // when loaded dynamically, the calling application must enable extension loading #if defined(SQLITE_CORE) && !defined(SQLITE_OMIT_LOAD_EXTENSION) @@ -369,13 +849,16 @@ static int dbmem_database_init (sqlite3 *db) { static bool dbmem_database_check_if_stored (sqlite3 *db, uint64_t hash, int64_t len) { static const char *sql = "SELECT length FROM dbmem_content WHERE hash=? LIMIT 1;"; - + + char hash_hex[17]; + dbmem_hash_to_hex(hash, hash_hex); + bool result = false; sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - - rc = sqlite3_bind_int64(vm, 1, (sqlite3_int64)hash); + + rc = sqlite3_bind_text(vm, 1, hash_hex, -1, SQLITE_STATIC); if (rc != SQLITE_OK) goto cleanup; rc = sqlite3_step(vm); @@ -391,21 +874,21 @@ static bool dbmem_database_check_if_stored (sqlite3 *db, uint64_t hash, int64_t return result; } -static void dbmem_database_delete_hash (sqlite3 *db, sqlite3_int64 hash) { +static void dbmem_database_delete_hash (sqlite3 *db, const char *hash) { sqlite3_stmt *vm = NULL; if (fts5_is_available) { sqlite3_prepare_v2(db, "DELETE FROM dbmem_vault_fts WHERE hash=?1;", -1, &vm, NULL); - sqlite3_bind_int64(vm, 1, hash); + sqlite3_bind_text(vm, 1, hash, -1, SQLITE_STATIC); sqlite3_step(vm); sqlite3_finalize(vm); } sqlite3_prepare_v2(db, "DELETE FROM dbmem_vault WHERE hash=?1;", -1, &vm, NULL); - sqlite3_bind_int64(vm, 1, hash); + sqlite3_bind_text(vm, 1, hash, -1, SQLITE_STATIC); sqlite3_step(vm); sqlite3_finalize(vm); sqlite3_prepare_v2(db, "DELETE FROM dbmem_content WHERE hash=?1;", -1, &vm, NULL); - sqlite3_bind_int64(vm, 1, hash); + sqlite3_bind_text(vm, 1, hash, -1, SQLITE_STATIC); sqlite3_step(vm); sqlite3_finalize(vm); } @@ -420,10 +903,19 @@ static void dbmem_database_delete_stale_path (sqlite3 *db, const char *path, uin sqlite3_bind_text(vm, 1, path, -1, SQLITE_STATIC); rc = sqlite3_step(vm); if (rc == SQLITE_ROW) { - sqlite3_int64 old_hash = sqlite3_column_int64(vm, 0); + const char *old_hash = (const char *)sqlite3_column_text(vm, 0); + char new_hash_hex[17]; + dbmem_hash_to_hex(new_hash, new_hash_hex); + bool stale = (old_hash && strcmp(old_hash, new_hash_hex) != 0); + // copy before finalizing so the pointer remains valid + char old_hash_copy[17]; + if (stale && old_hash) { + strncpy(old_hash_copy, old_hash, sizeof(old_hash_copy)); + old_hash_copy[16] = '\0'; + } sqlite3_finalize(vm); - if ((uint64_t)old_hash != new_hash) { - dbmem_database_delete_hash(db, old_hash); + if (stale) { + dbmem_database_delete_hash(db, old_hash_copy); } } else { sqlite3_finalize(vm); @@ -433,11 +925,14 @@ static void dbmem_database_delete_stale_path (sqlite3 *db, const char *path, uin static int dbmem_database_add_entry (dbmem_context *ctx, sqlite3 *db, uint64_t hash, const char *buffer, int64_t len) { static const char *sql = "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6);"; + char hash_hex[17]; + dbmem_hash_to_hex(hash, hash_hex); + sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - rc = sqlite3_bind_int64(vm, 1, (sqlite3_int64)hash); + rc = sqlite3_bind_text(vm, 1, hash_hex, -1, SQLITE_STATIC); if (rc != SQLITE_OK) goto cleanup; const char *path = ctx->path; @@ -470,12 +965,15 @@ static int dbmem_database_add_entry (dbmem_context *ctx, sqlite3 *db, uint64_t h static int dbmem_database_add_chunk (dbmem_context *ctx, embedding_result_t *result, size_t offset, size_t length, size_t index) { static const char *sql = "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES (?1, ?2, ?3, ?4, ?5);"; - + + char hash_hex[17]; + dbmem_hash_to_hex(ctx->hash, hash_hex); + sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(ctx->db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - - rc = sqlite3_bind_int64(vm, 1, (sqlite3_int64)ctx->hash); + + rc = sqlite3_bind_text(vm, 1, hash_hex, -1, SQLITE_STATIC); if (rc != SQLITE_OK) goto cleanup; rc = sqlite3_bind_int64(vm, 2, (sqlite3_int64)index); @@ -501,15 +999,18 @@ static int dbmem_database_add_chunk (dbmem_context *ctx, embedding_result_t *res static int dbmem_database_add_fts5 (dbmem_context *ctx, const char *text, size_t text_len, size_t index) { static const char *sql = "INSERT INTO dbmem_vault_fts (content, hash, seq, context) VALUES (?1, ?2, ?3, ?4);"; - + + char hash_hex[17]; + dbmem_hash_to_hex(ctx->hash, hash_hex); + sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(ctx->db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - + rc = sqlite3_bind_text(vm, 1, text, (int)text_len, SQLITE_STATIC); if (rc != SQLITE_OK) goto cleanup; - - rc = sqlite3_bind_int64(vm, 2, (sqlite3_int64)ctx->hash); + + rc = sqlite3_bind_text(vm, 2, hash_hex, -1, SQLITE_STATIC); if (rc != SQLITE_OK) goto cleanup; rc = sqlite3_bind_int64(vm, 3, (sqlite3_int64)index); @@ -720,12 +1221,12 @@ void dbmem_context_set_errorf (dbmem_context *ctx, const char *fmt, ...) { static void dbmem_delete (sqlite3_context *context, int argc, sqlite3_value **argv) { UNUSED_PARAM(argc); - if (sqlite3_value_type(argv[0]) != SQLITE_INTEGER) { - sqlite3_result_error(context, "The function memory_delete expects one argument of type INTEGER (hash)", SQLITE_ERROR); + if (sqlite3_value_type(argv[0]) != SQLITE_TEXT) { + sqlite3_result_error(context, "The function memory_delete expects one argument of type TEXT (hash)", SQLITE_ERROR); return; } - sqlite3_int64 hash = sqlite3_value_int64(argv[0]); + const char *hash = (const char *)sqlite3_value_text(argv[0]); sqlite3 *db = sqlite3_context_db_handle(context); int rc = dbmem_database_begin_transaction(db); @@ -739,7 +1240,7 @@ static void dbmem_delete (sqlite3_context *context, int argc, sqlite3_value **ar sqlite3_stmt *vm = NULL; rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_vault_fts WHERE hash = ?1;", -1, &vm, NULL); if (rc == SQLITE_OK) { - sqlite3_bind_int64(vm, 1, hash); + sqlite3_bind_text(vm, 1, hash, -1, SQLITE_STATIC); sqlite3_step(vm); sqlite3_finalize(vm); } @@ -749,7 +1250,7 @@ static void dbmem_delete (sqlite3_context *context, int argc, sqlite3_value **ar sqlite3_stmt *vm = NULL; rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_vault WHERE hash = ?1;", -1, &vm, NULL); if (rc != SQLITE_OK) goto rollback; - sqlite3_bind_int64(vm, 1, hash); + sqlite3_bind_text(vm, 1, hash, -1, SQLITE_STATIC); rc = sqlite3_step(vm); sqlite3_finalize(vm); if (rc != SQLITE_DONE) goto rollback; @@ -757,7 +1258,7 @@ static void dbmem_delete (sqlite3_context *context, int argc, sqlite3_value **ar // Delete from content rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_content WHERE hash = ?1;", -1, &vm, NULL); if (rc != SQLITE_OK) goto rollback; - sqlite3_bind_int64(vm, 1, hash); + sqlite3_bind_text(vm, 1, hash, -1, SQLITE_STATIC); rc = sqlite3_step(vm); sqlite3_finalize(vm); if (rc != SQLITE_DONE) goto rollback; @@ -1137,12 +1638,15 @@ static bool dbmem_cache_lookup (dbmem_context *ctx, uint64_t text_hash, embeddin if (!ctx->provider || !ctx->model) return false; + char hash_hex[17]; + dbmem_hash_to_hex(text_hash, hash_hex); + bool found = false; sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(ctx->db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - sqlite3_bind_int64(vm, 1, (sqlite3_int64)text_hash); + sqlite3_bind_text(vm, 1, hash_hex, -1, SQLITE_STATIC); sqlite3_bind_text(vm, 2, ctx->provider, -1, SQLITE_STATIC); sqlite3_bind_text(vm, 3, ctx->model, -1, SQLITE_STATIC); @@ -1207,11 +1711,14 @@ static void dbmem_cache_store (dbmem_context *ctx, uint64_t text_hash, const emb if (!ctx->provider || !ctx->model) return; + char hash_hex[17]; + dbmem_hash_to_hex(text_hash, hash_hex); + sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(ctx->db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - sqlite3_bind_int64(vm, 1, (sqlite3_int64)text_hash); + sqlite3_bind_text(vm, 1, hash_hex, -1, SQLITE_STATIC); sqlite3_bind_text(vm, 2, ctx->provider, -1, SQLITE_STATIC); sqlite3_bind_text(vm, 3, ctx->model, -1, SQLITE_STATIC); sqlite3_bind_blob(vm, 4, result->embedding, result->n_embd * (int)sizeof(float), SQLITE_STATIC); @@ -1498,7 +2005,7 @@ static void dbmem_database_delete_missing_files (sqlite3 *db, const char *dir_pa for (int i = 0; i < nrow; i++) { const char *path = table[ncol + i * ncol + 1]; if (dbmem_file_exists(path)) continue; - sqlite3_int64 hash = strtoll(table[ncol + i * ncol], NULL, 10); + const char *hash = table[ncol + i * ncol]; dbmem_database_delete_hash(db, hash); } dbmem_database_commit_transaction(db); @@ -1602,9 +2109,11 @@ static void dbmem_sql_reindex (sqlite3_context *context, int argc, sqlite3_value if (rc == SQLITE_OK && path) { static const char *fix_sql = "UPDATE dbmem_content SET hash = ?1 WHERE path = ?2 AND hash != ?1;"; + char fix_hash_hex[17]; + dbmem_hash_to_hex(ctx->hash, fix_hash_hex); sqlite3_stmt *fix_vm = NULL; if (sqlite3_prepare_v2(db, fix_sql, -1, &fix_vm, NULL) == SQLITE_OK) { - sqlite3_bind_int64(fix_vm, 1, (sqlite3_int64)ctx->hash); + sqlite3_bind_text(fix_vm, 1, fix_hash_hex, -1, SQLITE_STATIC); sqlite3_bind_text(fix_vm, 2, path, -1, SQLITE_STATIC); sqlite3_step(fix_vm); sqlite3_finalize(fix_vm); diff --git a/src/sqlite-memory.h b/src/sqlite-memory.h index f2ddfdc..f4aaefd 100644 --- a/src/sqlite-memory.h +++ b/src/sqlite-memory.h @@ -26,7 +26,7 @@ extern "C" { #endif -#define SQLITE_DBMEMORY_VERSION "0.9.0" +#define SQLITE_DBMEMORY_VERSION "1.0.0" // public API SQLITE_DBMEMORY_API int sqlite3_memory_init (sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi); diff --git a/test/e2e.c b/test/e2e.c index cb1f707..2555a12 100644 --- a/test/e2e.c +++ b/test/e2e.c @@ -332,12 +332,12 @@ TEST(memory_search) { ASSERT(rc == SQLITE_OK); ASSERT(sqlite3_step(stmt) == SQLITE_ROW); - int64_t hash = sqlite3_column_int64(stmt, 0); + const char *hash = (const char *)sqlite3_column_text(stmt, 0); const char *path = (const char *)sqlite3_column_text(stmt, 1); const char *snippet = (const char *)sqlite3_column_text(stmt, 3); double ranking = sqlite3_column_double(stmt, 4); - ASSERT(hash != 0); + ASSERT(hash != NULL && strlen(hash) == 16); ASSERT(path != NULL && strlen(path) > 0); ASSERT(snippet != NULL && strlen(snippet) > 0); ASSERT(ranking > 0.0 && ranking <= 1.0); @@ -381,14 +381,15 @@ TEST(memory_search_ranking) { // Phase 5: Deletion // ============================================================================ -// memory_delete: delete by hash +// memory_delete: delete by hash (hash is now TEXT, 16-char hex) TEST(memory_delete) { // Get a hash from a context-less entry - int64_t hash = 0; + char hash[17] = {0}; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(db, "SELECT hash FROM dbmem_content WHERE context IS NULL LIMIT 1;", -1, &stmt, NULL); ASSERT(rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW); - hash = sqlite3_column_int64(stmt, 0); + const char *h = (const char *)sqlite3_column_text(stmt, 0); + if (h) strncpy(hash, h, 16); sqlite3_finalize(stmt); result_int = 0; @@ -396,7 +397,7 @@ TEST(memory_delete) { int before = result_int; char sql[128]; - snprintf(sql, sizeof(sql), "SELECT memory_delete(%lld);", (long long)hash); + snprintf(sql, sizeof(sql), "SELECT memory_delete('%s');", hash); ASSERT_SQL_OK(db, sql); // Verify count decreased by 1 diff --git a/test/unittest.c b/test/unittest.c index 7b47fc2..8661ec6 100644 --- a/test/unittest.c +++ b/test/unittest.c @@ -1355,6 +1355,194 @@ static int exec_get_text(sqlite3 *db, const char *sql, char *result, size_t max_ return rc; } +typedef struct { + int init_calls; + int set_column_calls; + int set_filter_calls; + int clear_filter_calls; + int cleanup_calls; + int fail_init; + int fail_set_column; + int fail_set_filter; + int fail_clear_filter; + int fail_cleanup; +} fake_cloudsync_t; + +static void fake_cloudsync_result(sqlite3_context *context, int rc) { + sqlite3 *db = sqlite3_context_db_handle(context); + if (rc != SQLITE_OK) { + sqlite3_result_error(context, sqlite3_errmsg(db), -1); + return; + } + sqlite3_result_int(context, 1); +} + +static void fake_cloudsync_version(sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); + UNUSED_PARAM(argv); + sqlite3_result_text(context, "test-cloudsync", -1, SQLITE_STATIC); +} + +static void fake_cloudsync_init(sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); + fake_cloudsync_t *state = (fake_cloudsync_t *)sqlite3_user_data(context); + sqlite3 *db = sqlite3_context_db_handle(context); + const char *table = (const char *)sqlite3_value_text(argv[0]); + const char *algo = (const char *)sqlite3_value_text(argv[1]); + + state->init_calls++; + if (state->fail_init) { + sqlite3_result_error(context, "fake cloudsync_init failed", -1); + return; + } + + int rc = sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS cloudsync_table_settings (" + "tbl_name TEXT NOT NULL COLLATE NOCASE, " + "col_name TEXT NOT NULL COLLATE NOCASE, " + "key TEXT, value TEXT, " + "PRIMARY KEY(tbl_name,col_name,key));", + NULL, NULL, NULL); + if (rc != SQLITE_OK) { + fake_cloudsync_result(context, rc); + return; + } + + char *sql = sqlite3_mprintf( + "REPLACE INTO cloudsync_table_settings (tbl_name, col_name, key, value) " + "VALUES ('%q', '*', 'algo', '%q');", + table, algo); + if (!sql) { + sqlite3_result_error_nomem(context); + return; + } + + rc = sqlite3_exec(db, sql, NULL, NULL, NULL); + sqlite3_free(sql); + fake_cloudsync_result(context, rc); +} + +static void fake_cloudsync_set_column(sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); + fake_cloudsync_t *state = (fake_cloudsync_t *)sqlite3_user_data(context); + sqlite3 *db = sqlite3_context_db_handle(context); + const char *table = (const char *)sqlite3_value_text(argv[0]); + const char *column = (const char *)sqlite3_value_text(argv[1]); + const char *key = (const char *)sqlite3_value_text(argv[2]); + const char *value = (const char *)sqlite3_value_text(argv[3]); + + state->set_column_calls++; + if (state->fail_set_column) { + sqlite3_result_error(context, "fake cloudsync_set_column failed", -1); + return; + } + + char *sql = sqlite3_mprintf( + "REPLACE INTO cloudsync_table_settings (tbl_name, col_name, key, value) " + "VALUES ('%q', '%q', '%q', '%q');", + table, column, key, value); + if (!sql) { + sqlite3_result_error_nomem(context); + return; + } + + int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); + sqlite3_free(sql); + fake_cloudsync_result(context, rc); +} + +static void fake_cloudsync_set_filter(sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); + fake_cloudsync_t *state = (fake_cloudsync_t *)sqlite3_user_data(context); + sqlite3 *db = sqlite3_context_db_handle(context); + const char *table = (const char *)sqlite3_value_text(argv[0]); + const char *filter = (const char *)sqlite3_value_text(argv[1]); + + state->set_filter_calls++; + if (state->fail_set_filter) { + sqlite3_result_error(context, "fake cloudsync_set_filter failed", -1); + return; + } + + char *sql = sqlite3_mprintf( + "REPLACE INTO cloudsync_table_settings (tbl_name, col_name, key, value) " + "VALUES ('%q', '*', 'filter', '%q');", + table, filter); + if (!sql) { + sqlite3_result_error_nomem(context); + return; + } + + int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); + sqlite3_free(sql); + fake_cloudsync_result(context, rc); +} + +static void fake_cloudsync_clear_filter(sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); + fake_cloudsync_t *state = (fake_cloudsync_t *)sqlite3_user_data(context); + sqlite3 *db = sqlite3_context_db_handle(context); + const char *table = (const char *)sqlite3_value_text(argv[0]); + + state->clear_filter_calls++; + if (state->fail_clear_filter) { + sqlite3_result_error(context, "fake cloudsync_clear_filter failed", -1); + return; + } + + char *sql = sqlite3_mprintf( + "DELETE FROM cloudsync_table_settings " + "WHERE tbl_name='%q' AND col_name='*' AND key='filter';", + table); + if (!sql) { + sqlite3_result_error_nomem(context); + return; + } + + int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); + sqlite3_free(sql); + fake_cloudsync_result(context, rc); +} + +static void fake_cloudsync_cleanup(sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); + fake_cloudsync_t *state = (fake_cloudsync_t *)sqlite3_user_data(context); + sqlite3 *db = sqlite3_context_db_handle(context); + const char *table = (const char *)sqlite3_value_text(argv[0]); + + state->cleanup_calls++; + if (state->fail_cleanup) { + sqlite3_result_error(context, "fake cloudsync_cleanup failed", -1); + return; + } + + char *sql = sqlite3_mprintf( + "DELETE FROM cloudsync_table_settings WHERE tbl_name='%q';", + table); + if (!sql) { + sqlite3_result_error_nomem(context); + return; + } + + int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); + sqlite3_free(sql); + fake_cloudsync_result(context, rc); +} + +static int install_fake_cloudsync(sqlite3 *db, fake_cloudsync_t *state) { + int rc = sqlite3_create_function_v2(db, "cloudsync_version", 0, SQLITE_UTF8, state, fake_cloudsync_version, NULL, NULL, NULL); + if (rc != SQLITE_OK) return rc; + rc = sqlite3_create_function_v2(db, "cloudsync_init", 3, SQLITE_UTF8, state, fake_cloudsync_init, NULL, NULL, NULL); + if (rc != SQLITE_OK) return rc; + rc = sqlite3_create_function_v2(db, "cloudsync_set_column", 4, SQLITE_UTF8, state, fake_cloudsync_set_column, NULL, NULL, NULL); + if (rc != SQLITE_OK) return rc; + rc = sqlite3_create_function_v2(db, "cloudsync_set_filter", 2, SQLITE_UTF8, state, fake_cloudsync_set_filter, NULL, NULL, NULL); + if (rc != SQLITE_OK) return rc; + rc = sqlite3_create_function_v2(db, "cloudsync_clear_filter", 1, SQLITE_UTF8, state, fake_cloudsync_clear_filter, NULL, NULL, NULL); + if (rc != SQLITE_OK) return rc; + return sqlite3_create_function_v2(db, "cloudsync_cleanup", 1, SQLITE_UTF8, state, fake_cloudsync_cleanup, NULL, NULL, NULL); +} + TEST(sqlite_memory_version) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); @@ -1364,6 +1552,7 @@ TEST(sqlite_memory_version) { ASSERT_EQ(rc, SQLITE_OK); ASSERT(strlen(version) > 0); ASSERT(strstr(version, ".") != NULL); // Version should contain a dot + ASSERT_STR_EQ(version, "1.0.0"); sqlite3_close(db); } @@ -1385,7 +1574,7 @@ TEST(sqlite_memory_delete_nonexistent) { ASSERT(db != NULL); sqlite3_int64 result; - int rc = exec_get_int(db, "SELECT memory_delete(12345);", &result); + int rc = exec_get_int(db, "SELECT memory_delete('0000000000003039');", &result); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(result, 0); // Should return 0 (no rows deleted) @@ -1408,14 +1597,15 @@ TEST(sqlite_schema_has_timestamps) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Check that schema includes created_at column - char sql[256]; + // Check that schema includes created_at column and uses TEXT hash (1.0.0+) + char sql[512]; int rc = exec_get_text(db, "SELECT sql FROM sqlite_master WHERE name='dbmem_content';", sql, sizeof(sql)); ASSERT_EQ(rc, SQLITE_OK); ASSERT(strstr(sql, "created_at") != NULL); ASSERT(strstr(sql, "last_accessed") != NULL); + ASSERT(strstr(sql, "hash TEXT") != NULL); sqlite3_close(db); } @@ -1425,10 +1615,10 @@ TEST(sqlite_direct_insert_with_timestamp) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert a test record directly + // Insert a test record directly (hash is now TEXT, 16-char hex) int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (123, 'test/path', 'test value', 10, 'ctx1', strftime('%s','now'));", + "VALUES ('000000000000007b', 'test/path', 'test value', 10, 'ctx1', strftime('%s','now'));", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -1440,7 +1630,7 @@ TEST(sqlite_direct_insert_with_timestamp) { // Verify created_at was set sqlite3_int64 created_at; - rc = exec_get_int(db, "SELECT created_at FROM dbmem_content WHERE hash=123;", &created_at); + rc = exec_get_int(db, "SELECT created_at FROM dbmem_content WHERE hash='000000000000007b';", &created_at); ASSERT_EQ(rc, SQLITE_OK); ASSERT(created_at > 0); // Should be a valid Unix timestamp @@ -1451,16 +1641,16 @@ TEST(sqlite_memory_delete_direct) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert a test record directly + // Insert a test record directly (hash is now TEXT, 16-char hex; 456 = 0x1c8) int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (456, 'test/path2', 'test value 2', 12, 'ctx2', strftime('%s','now'));", + "VALUES ('00000000000001c8', 'test/path2', 'test value 2', 12, 'ctx2', strftime('%s','now'));", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Delete it + // Delete it using the TEXT hash sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_delete(456);", &result); + rc = exec_get_int(db, "SELECT memory_delete('00000000000001c8');", &result); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(result, 1); // Should have deleted 1 row @@ -1477,12 +1667,13 @@ TEST(sqlite_memory_delete_context_direct) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert test records with different contexts + // Insert test records with different contexts (hashes as 16-char hex) + // 100=0x64, 101=0x65, 102=0x66 int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " - "(100, 'path1', 'v1', 2, 'ctx_a', 0), " - "(101, 'path2', 'v2', 2, 'ctx_a', 0), " - "(102, 'path3', 'v3', 2, 'ctx_b', 0);", + "('0000000000000064', 'path1', 'v1', 2, 'ctx_a', 0), " + "('0000000000000065', 'path2', 'v2', 2, 'ctx_a', 0), " + "('0000000000000066', 'path3', 'v3', 2, 'ctx_b', 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -1511,11 +1702,11 @@ TEST(sqlite_memory_clear_direct) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert test records + // Insert test records (200=0xc8, 201=0xc9) int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " - "(200, 'p1', 'v1', 2, 'c1', 0), " - "(201, 'p2', 'v2', 2, 'c2', 0);", + "('00000000000000c8', 'p1', 'v1', 2, 'c1', 0), " + "('00000000000000c9', 'p2', 'v2', 2, 'c2', 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -1538,39 +1729,39 @@ TEST(sqlite_memory_delete_with_vault_data) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert into content and vault tables + // Insert into content and vault tables (300 = 0x12c) int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (300, 'path300', 'value', 5, 'ctx', 0);", + "VALUES ('000000000000012c', 'path300', 'value', 5, 'ctx', 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); rc = sqlite3_exec(db, "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) " - "VALUES (300, 0, X'00000000', 0, 5), (300, 1, X'00000000', 5, 5);", + "VALUES ('000000000000012c', 0, X'00000000', 0, 5), ('000000000000012c', 1, X'00000000', 5, 5);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); // Verify vault has data sqlite3_int64 vault_count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash=300;", &vault_count); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash='000000000000012c';", &vault_count); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(vault_count, 2); - // Delete by hash + // Delete by hash (TEXT) sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_delete(300);", &result); + rc = exec_get_int(db, "SELECT memory_delete('000000000000012c');", &result); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(result, 1); // Verify content is gone sqlite3_int64 content_count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE hash=300;", &content_count); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE hash='000000000000012c';", &content_count); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(content_count, 0); // Verify vault is also gone - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash=300;", &vault_count); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash='000000000000012c';", &vault_count); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(vault_count, 0); @@ -1581,21 +1772,21 @@ TEST(sqlite_memory_delete_twice) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert a record + // Insert a record (400 = 0x190) int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (400, 'path400', 'value', 5, 'ctx', 0);", + "VALUES ('0000000000000190', 'path400', 'value', 5, 'ctx', 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); // Delete first time - should return 1 sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_delete(400);", &result); + rc = exec_get_int(db, "SELECT memory_delete('0000000000000190');", &result); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(result, 1); // Delete second time - should return 0 - rc = exec_get_int(db, "SELECT memory_delete(400);", &result); + rc = exec_get_int(db, "SELECT memory_delete('0000000000000190');", &result); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(result, 0); @@ -1606,12 +1797,12 @@ TEST(sqlite_memory_delete_context_null) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert records - some with NULL context, some with context + // Insert records - some with NULL context, some with context (500=0x1f4, 501=0x1f5, 502=0x1f6) int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " - "(500, 'p1', 'v1', 2, NULL, 0), " - "(501, 'p2', 'v2', 2, NULL, 0), " - "(502, 'p3', 'v3', 2, 'has_context', 0);", + "('00000000000001f4', 'p1', 'v1', 2, NULL, 0), " + "('00000000000001f5', 'p2', 'v2', 2, NULL, 0), " + "('00000000000001f6', 'p3', 'v3', 2, 'has_context', 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -1643,14 +1834,21 @@ TEST(sqlite_memory_delete_wrong_type) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Try to call memory_delete with TEXT instead of INTEGER + // memory_delete now expects TEXT; passing an INTEGER should return an error sqlite3_stmt *stmt = NULL; - int rc = sqlite3_prepare_v2(db, "SELECT memory_delete('not_a_number');", -1, &stmt, NULL); + int rc = sqlite3_prepare_v2(db, "SELECT memory_delete(42);", -1, &stmt, NULL); ASSERT_EQ(rc, SQLITE_OK); rc = sqlite3_step(stmt); - // Should return an error - ASSERT(rc == SQLITE_ERROR || rc == SQLITE_ROW); // Implementation may vary + ASSERT_EQ(rc, SQLITE_ERROR); + sqlite3_finalize(stmt); + + // Passing a TEXT hash (even one that doesn't match any row) should succeed, returning 0 + rc = sqlite3_prepare_v2(db, "SELECT memory_delete('0000000000000000');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + ASSERT_EQ(sqlite3_column_int64(stmt, 0), 0); sqlite3_finalize(stmt); sqlite3_close(db); @@ -1705,16 +1903,16 @@ TEST(sqlite_memory_created_at_valid_range) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert with current timestamp + // Insert with current timestamp (600 = 0x258) int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (600, 'path600', 'value', 5, 'ctx', strftime('%s','now'));", + "VALUES ('0000000000000258', 'path600', 'value', 5, 'ctx', strftime('%s','now'));", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); // Get the created_at value sqlite3_int64 created_at; - rc = exec_get_int(db, "SELECT created_at FROM dbmem_content WHERE hash=600;", &created_at); + rc = exec_get_int(db, "SELECT created_at FROM dbmem_content WHERE hash='0000000000000258';", &created_at); ASSERT_EQ(rc, SQLITE_OK); // Should be greater than 0 @@ -1736,22 +1934,22 @@ TEST(sqlite_memory_clear_with_vault_fts) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert into all tables + // Insert into all tables (700 = 0x2bc) int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (700, 'path700', 'value', 5, 'ctx', 0);", + "VALUES ('00000000000002bc', 'path700', 'value', 5, 'ctx', 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); rc = sqlite3_exec(db, "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) " - "VALUES (700, 0, X'00000000', 0, 5);", + "VALUES ('00000000000002bc', 0, X'00000000', 0, 5);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); rc = sqlite3_exec(db, "INSERT INTO dbmem_vault_fts (content, hash, seq, context) " - "VALUES ('test content', 700, 0, 'ctx');", + "VALUES ('test content', '00000000000002bc', 0, 'ctx');", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -1778,14 +1976,15 @@ TEST(sqlite_memory_clear_with_vault_fts) { sqlite3_close(db); } -// Helper to insert a fake dbmem_content entry with a known path, hash, and length -static int insert_fake_content(sqlite3 *db, sqlite3_int64 hash, const char *path, const char *context, sqlite3_int64 length) { +// Helper to insert a fake dbmem_content entry with a known path, hash, and length. +// hash_hex must be a 16-char lowercase hex string (e.g. "000000000000007b"). +static int insert_fake_content(sqlite3 *db, const char *hash_hex, const char *path, const char *context, sqlite3_int64 length) { sqlite3_stmt *vm = NULL; const char *sql = "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " "VALUES (?1, ?2, 'fake', ?3, ?4, 0);"; int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) return rc; - sqlite3_bind_int64(vm, 1, hash); + sqlite3_bind_text(vm, 1, hash_hex, -1, SQLITE_STATIC); sqlite3_bind_text(vm, 2, path, -1, SQLITE_STATIC); sqlite3_bind_int64(vm, 3, length); if (context) sqlite3_bind_text(vm, 4, context, -1, SQLITE_STATIC); @@ -1795,6 +1994,11 @@ static int insert_fake_content(sqlite3 *db, sqlite3_int64 hash, const char *path return (rc == SQLITE_DONE) ? SQLITE_OK : rc; } +// Helper: convert uint64_t hash to 16-char lowercase hex string +static void hash_to_hex(uint64_t hash, char buf[17]) { + snprintf(buf, 17, "%016llx", (unsigned long long)hash); +} + TEST(sqlite_sync_directory_removes_deleted) { // Test that memory_add_directory removes entries for files no longer on disk sqlite3 *db = open_test_db(); @@ -1820,10 +2024,13 @@ TEST(sqlite_sync_directory_removes_deleted) { uint64_t keep_hash = dbmem_hash_compute(buf, (size_t)len); dbmemory_free(buf); - int rc = insert_fake_content(db, (sqlite3_int64)keep_hash, file_keep, NULL, len); + char keep_hash_hex[17]; + hash_to_hex(keep_hash, keep_hash_hex); + + int rc = insert_fake_content(db, keep_hash_hex, file_keep, NULL, len); ASSERT_EQ(rc, SQLITE_OK); - rc = insert_fake_content(db, 99999, "/tmp/dbmem_test_sync_del/gone.md", NULL, 4); + rc = insert_fake_content(db, "000000000001869f", "/tmp/dbmem_test_sync_del/gone.md", NULL, 4); ASSERT_EQ(rc, SQLITE_OK); // Verify 2 entries before sync @@ -1862,20 +2069,20 @@ TEST(sqlite_sync_directory_removes_all_deleted) { rmdir_p(test_dir); mkdir_p(test_dir); // empty directory - // Insert fake entries pointing to files that don't exist - int rc = insert_fake_content(db, 1001, "/tmp/dbmem_test_sync_allgone/a.md", "ctx", 4); + // Insert fake entries pointing to files that don't exist (1001=0x3e9, 1002=0x3ea, 1003=0x3eb) + int rc = insert_fake_content(db, "00000000000003e9", "/tmp/dbmem_test_sync_allgone/a.md", "ctx", 4); ASSERT_EQ(rc, SQLITE_OK); - rc = insert_fake_content(db, 1002, "/tmp/dbmem_test_sync_allgone/b.md", "ctx", 4); + rc = insert_fake_content(db, "00000000000003ea", "/tmp/dbmem_test_sync_allgone/b.md", "ctx", 4); ASSERT_EQ(rc, SQLITE_OK); - rc = insert_fake_content(db, 1003, "/tmp/dbmem_test_sync_allgone/c.md", "ctx", 4); + rc = insert_fake_content(db, "00000000000003eb", "/tmp/dbmem_test_sync_allgone/c.md", "ctx", 4); ASSERT_EQ(rc, SQLITE_OK); // Also insert vault entries to verify cascade delete rc = sqlite3_exec(db, "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES " - "(1001, 0, X'00000000', 0, 4), " - "(1002, 0, X'00000000', 0, 4), " - "(1003, 0, X'00000000', 0, 4);", + "('00000000000003e9', 0, X'00000000', 0, 4), " + "('00000000000003ea', 0, X'00000000', 0, 4), " + "('00000000000003eb', 0, X'00000000', 0, 4);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -1918,7 +2125,9 @@ TEST(sqlite_sync_directory_skips_unchanged) { // Compute the hash and pre-insert the entry uint64_t hash = dbmem_hash_compute(content, strlen(content)); - int rc = insert_fake_content(db, (sqlite3_int64)hash, file, "notes", (sqlite3_int64)strlen(content)); + char hash_hex[17]; + hash_to_hex(hash, hash_hex); + int rc = insert_fake_content(db, hash_hex, file, "notes", (sqlite3_int64)strlen(content)); ASSERT_EQ(rc, SQLITE_OK); // Sync — file exists with matching hash, should be skipped @@ -1972,12 +2181,12 @@ TEST(sqlite_cache_clear_with_data) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert some fake cache entries + // Insert some fake cache entries (text_hash is now TEXT hex; 111=0x6f, 222=0xde, 333=0x14d) int rc = sqlite3_exec(db, "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " - "(111, 'openai', 'text-embedding-3-small', X'00000000', 1), " - "(222, 'openai', 'text-embedding-3-small', X'00000000', 1), " - "(333, 'local', 'nomic', X'00000000', 1);", + "('000000000000006f', 'openai', 'text-embedding-3-small', X'00000000', 1), " + "('00000000000000de', 'openai', 'text-embedding-3-small', X'00000000', 1), " + "('000000000000014d', 'local', 'nomic', X'00000000', 1);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -2000,12 +2209,12 @@ TEST(sqlite_cache_clear_by_provider_model) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert cache entries for different provider/model combos + // Insert cache entries for different provider/model combos (text_hash as TEXT hex) int rc = sqlite3_exec(db, "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " - "(111, 'openai', 'text-embedding-3-small', X'00000000', 1), " - "(222, 'openai', 'text-embedding-3-small', X'00000000', 1), " - "(333, 'local', 'nomic', X'00000000', 1);", + "('000000000000006f', 'openai', 'text-embedding-3-small', X'00000000', 1), " + "('00000000000000de', 'openai', 'text-embedding-3-small', X'00000000', 1), " + "('000000000000014d', 'local', 'nomic', X'00000000', 1);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -2090,14 +2299,14 @@ TEST(sqlite_cache_eviction) { int rc = exec_get_int(db, "SELECT memory_set_option('cache_max_entries', 3);", &result); ASSERT_EQ(rc, SQLITE_OK); - // Insert 5 entries (rowids 1-5) + // Insert 5 entries (rowids 1-5; text_hash as TEXT hex) rc = sqlite3_exec(db, "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " - "(1, 'p', 'm', X'00000000', 1), " - "(2, 'p', 'm', X'00000000', 1), " - "(3, 'p', 'm', X'00000000', 1), " - "(4, 'p', 'm', X'00000000', 1), " - "(5, 'p', 'm', X'00000000', 1);", + "('0000000000000001', 'p', 'm', X'00000000', 1), " + "('0000000000000002', 'p', 'm', X'00000000', 1), " + "('0000000000000003', 'p', 'm', X'00000000', 1), " + "('0000000000000004', 'p', 'm', X'00000000', 1), " + "('0000000000000005', 'p', 'm', X'00000000', 1);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -2114,9 +2323,9 @@ TEST(sqlite_cache_eviction) { // Insert exactly 3 (at limit) rc = sqlite3_exec(db, "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " - "(10, 'p', 'm', X'00000000', 1), " - "(11, 'p', 'm', X'00000000', 1), " - "(12, 'p', 'm', X'00000000', 1);", + "('000000000000000a', 'p', 'm', X'00000000', 1), " + "('000000000000000b', 'p', 'm', X'00000000', 1), " + "('000000000000000c', 'p', 'm', X'00000000', 1);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -2132,14 +2341,14 @@ TEST(sqlite_cache_no_eviction_when_unlimited) { ASSERT(db != NULL); // Default cache_max_entries is 0 (no limit) - // Insert many entries, none should be evicted + // Insert many entries, none should be evicted (text_hash as TEXT hex) int rc = sqlite3_exec(db, "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " - "(1, 'p', 'm', X'00000000', 1), " - "(2, 'p', 'm', X'00000000', 1), " - "(3, 'p', 'm', X'00000000', 1), " - "(4, 'p', 'm', X'00000000', 1), " - "(5, 'p', 'm', X'00000000', 1);", + "('0000000000000001', 'p', 'm', X'00000000', 1), " + "('0000000000000002', 'p', 'm', X'00000000', 1), " + "('0000000000000003', 'p', 'm', X'00000000', 1), " + "('0000000000000004', 'p', 'm', X'00000000', 1), " + "('0000000000000005', 'p', 'm', X'00000000', 1);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -2181,18 +2390,18 @@ TEST(sqlite_memory_delete_context_with_vault) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert records with different contexts into content and vault + // Insert records with different contexts into content and vault (800=0x320, 801=0x321) int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " - "(800, 'p1', 'v1', 2, 'delete_me', 0), " - "(801, 'p2', 'v2', 2, 'keep_me', 0);", + "('0000000000000320', 'p1', 'v1', 2, 'delete_me', 0), " + "('0000000000000321', 'p2', 'v2', 2, 'keep_me', 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); rc = sqlite3_exec(db, "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES " - "(800, 0, X'00000000', 0, 2), " - "(801, 0, X'00000000', 0, 2);", + "('0000000000000320', 0, X'00000000', 0, 2), " + "('0000000000000321', 0, X'00000000', 0, 2);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); @@ -2213,9 +2422,10 @@ TEST(sqlite_memory_delete_context_with_vault) { ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(count, 1); - rc = exec_get_int(db, "SELECT hash FROM dbmem_vault;", &result); + char vault_hash[32]; + rc = exec_get_text(db, "SELECT hash FROM dbmem_vault;", vault_hash, sizeof(vault_hash)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 801); + ASSERT_STR_EQ(vault_hash, "0000000000000321"); sqlite3_close(db); } @@ -2364,6 +2574,524 @@ TEST(sqlite_custom_provider_init_error) { sqlite3_close(db); } +TEST(sqlite_schema_migration) { + // Create an in-memory database with the OLD schema (INTEGER hash, pre-1.0.0) + // then call sqlite3_memory_init and verify migration happened correctly. + sqlite3 *db = NULL; + int rc = sqlite3_open(":memory:", &db); + ASSERT_EQ(rc, SQLITE_OK); + + // Build old schema manually (INTEGER hash) + rc = sqlite3_exec(db, + "CREATE TABLE dbmem_settings (key TEXT PRIMARY KEY, value TEXT);" + "CREATE TABLE dbmem_content (" + " hash INTEGER PRIMARY KEY NOT NULL," + " path TEXT NOT NULL DEFAULT '' UNIQUE," + " value TEXT DEFAULT NULL," + " length INTEGER NOT NULL DEFAULT 0," + " context TEXT DEFAULT NULL," + " created_at INTEGER DEFAULT 0," + " last_accessed INTEGER DEFAULT 0);" + "CREATE TABLE dbmem_vault (" + " hash INTEGER NOT NULL," + " seq INTEGER NOT NULL," + " embedding BLOB NOT NULL," + " offset INTEGER NOT NULL," + " length INTEGER NOT NULL," + " PRIMARY KEY (hash, seq));" + "CREATE TABLE dbmem_cache (" + " text_hash INTEGER NOT NULL," + " provider TEXT NOT NULL," + " model TEXT NOT NULL," + " embedding BLOB NOT NULL," + " dimension INTEGER NOT NULL," + " PRIMARY KEY (text_hash, provider, model));", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + // Insert sample data with INTEGER hash values + // Use small values that fit in uint64_t and round-trip cleanly through printf('%016x') + rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES" + " (255, 'path_a', 'content a', 9, 'ctx', 1000)," + " (4096, 'path_b', 'content b', 9, NULL, 2000);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_exec(db, + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES" + " (255, 0, X'0000803f', 0, 9)," + " (4096, 0, X'0000803f', 0, 9);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_exec(db, + "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES" + " (255, 'test', 'model', X'0000803f', 1);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + // Run the extension init — this triggers schema migration + rc = sqlite3_memory_init(db, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + // Verify hash column is now TEXT + char schema[512]; + rc = exec_get_text(db, + "SELECT sql FROM sqlite_master WHERE name='dbmem_content';", + schema, sizeof(schema)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strstr(schema, "hash TEXT") != NULL); + + // Verify the content rows are present with hex-encoded hashes + // 255 = 0x00000000000000ff + // 4096 = 0x0000000000001000 + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 2); + + char hash_val[64]; + rc = exec_get_text(db, + "SELECT hash FROM dbmem_content WHERE path='path_a';", + hash_val, sizeof(hash_val)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(hash_val, "00000000000000ff"); + + rc = exec_get_text(db, + "SELECT hash FROM dbmem_content WHERE path='path_b';", + hash_val, sizeof(hash_val)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(hash_val, "0000000000001000"); + + // Verify vault rows migrated correctly + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 2); + + rc = exec_get_text(db, + "SELECT hash FROM dbmem_vault WHERE seq=0 AND length=9 AND hash='00000000000000ff';", + hash_val, sizeof(hash_val)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(hash_val, "00000000000000ff"); + + // Verify cache migrated correctly + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = exec_get_text(db, + "SELECT text_hash FROM dbmem_cache;", + hash_val, sizeof(hash_val)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(hash_val, "00000000000000ff"); + + // Verify that content data is preserved + sqlite3_int64 created_at; + rc = exec_get_int(db, + "SELECT created_at FROM dbmem_content WHERE hash='00000000000000ff';", + &created_at); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(created_at, 1000); + + // Verify memory_delete works with the migrated TEXT hash + sqlite3_int64 deleted; + rc = exec_get_int(db, "SELECT memory_delete('00000000000000ff');", &deleted); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(deleted, 1); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + sqlite3_close(db); +} + +TEST(sqlite_schema_migration_preserves_cloudsync_filter) { + sqlite3 *db = NULL; + int rc = sqlite3_open(":memory:", &db); + ASSERT_EQ(rc, SQLITE_OK); + + fake_cloudsync_t sync = {0}; + rc = install_fake_cloudsync(db, &sync); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_exec(db, + "CREATE TABLE dbmem_settings (key TEXT PRIMARY KEY, value TEXT);" + "CREATE TABLE cloudsync_table_settings (" + " tbl_name TEXT NOT NULL COLLATE NOCASE," + " col_name TEXT NOT NULL COLLATE NOCASE," + " key TEXT, value TEXT," + " PRIMARY KEY(tbl_name,col_name,key));" + "INSERT INTO cloudsync_table_settings (tbl_name, col_name, key, value) VALUES" + " ('dbmem_content', '*', 'algo', 'cls')," + " ('dbmem_content', 'path', 'algo', 'plain')," + " ('dbmem_content', 'value', 'algo', 'block')," + " ('dbmem_content', '*', 'filter', 'context IN (''ctx_a'',''ctx_b'')');" + "CREATE TABLE dbmem_content (" + " hash INTEGER PRIMARY KEY NOT NULL," + " path TEXT NOT NULL DEFAULT '' UNIQUE," + " value TEXT DEFAULT NULL," + " length INTEGER NOT NULL DEFAULT 0," + " context TEXT DEFAULT NULL," + " created_at INTEGER DEFAULT 0," + " last_accessed INTEGER DEFAULT 0);" + "CREATE TABLE dbmem_vault (" + " hash INTEGER NOT NULL," + " seq INTEGER NOT NULL," + " embedding BLOB NOT NULL," + " offset INTEGER NOT NULL," + " length INTEGER NOT NULL," + " PRIMARY KEY (hash, seq));" + "INSERT INTO dbmem_content (hash, path, value, length, context) VALUES" + " (255, 'path_a', 'content a', 9, 'ctx_a');" + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES" + " (255, 0, X'0000803f', 0, 9);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_memory_init(db, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(sync.cleanup_calls, 0); + ASSERT_EQ(sync.init_calls, 0); + ASSERT_EQ(sync.set_column_calls, 0); + ASSERT_EQ(sync.set_filter_calls, 0); + ASSERT_EQ(sync.clear_filter_calls, 0); + + char filter[128]; + rc = exec_get_text(db, + "SELECT value FROM cloudsync_table_settings " + "WHERE tbl_name='dbmem_content' AND col_name='*' AND key='filter';", + filter, sizeof(filter)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(filter, "context IN ('ctx_a','ctx_b')"); + + char path_algo[32]; + rc = exec_get_text(db, + "SELECT value FROM cloudsync_table_settings " + "WHERE tbl_name='dbmem_content' AND col_name='path' AND key='algo';", + path_algo, sizeof(path_algo)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(path_algo, "plain"); + + char hash_type[32]; + rc = exec_get_text(db, + "SELECT type FROM pragma_table_info('dbmem_content') WHERE name='hash';", + hash_type, sizeof(hash_type)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(hash_type, "TEXT"); + + sqlite3_close(db); +} + +TEST(sqlite_schema_migration_requires_cloudsync_when_synced) { + sqlite3 *db = NULL; + int rc = sqlite3_open(":memory:", &db); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_exec(db, + "CREATE TABLE dbmem_settings (key TEXT PRIMARY KEY, value TEXT);" + "CREATE TABLE cloudsync_table_settings (" + " tbl_name TEXT NOT NULL COLLATE NOCASE," + " col_name TEXT NOT NULL COLLATE NOCASE," + " key TEXT, value TEXT," + " PRIMARY KEY(tbl_name,col_name,key));" + "INSERT INTO cloudsync_table_settings (tbl_name, col_name, key, value) VALUES" + " ('dbmem_content', '*', 'algo', 'cls')," + " ('dbmem_content', 'value', 'algo', 'block')," + " ('dbmem_content', '*', 'filter', 'context IN (''ctx_a'')');" + "CREATE TABLE dbmem_content (" + " hash INTEGER PRIMARY KEY NOT NULL," + " path TEXT NOT NULL DEFAULT '' UNIQUE," + " value TEXT DEFAULT NULL," + " length INTEGER NOT NULL DEFAULT 0," + " context TEXT DEFAULT NULL," + " created_at INTEGER DEFAULT 0," + " last_accessed INTEGER DEFAULT 0);" + "CREATE TABLE dbmem_vault (" + " hash INTEGER NOT NULL," + " seq INTEGER NOT NULL," + " embedding BLOB NOT NULL," + " offset INTEGER NOT NULL," + " length INTEGER NOT NULL," + " PRIMARY KEY (hash, seq));", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_memory_init(db, NULL, NULL); + ASSERT_EQ(rc, SQLITE_ERROR); + + char hash_type[32]; + rc = exec_get_text(db, + "SELECT type FROM pragma_table_info('dbmem_content') WHERE name='hash';", + hash_type, sizeof(hash_type)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(hash_type, "INTEGER"); + + sqlite3_close(db); +} + +TEST(sqlite_schema_migration_ignores_user_triggers) { + sqlite3 *db = NULL; + int rc = sqlite3_open(":memory:", &db); + ASSERT_EQ(rc, SQLITE_OK); + + fake_cloudsync_t sync = {0}; + rc = install_fake_cloudsync(db, &sync); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_exec(db, + "CREATE TABLE dbmem_settings (key TEXT PRIMARY KEY, value TEXT);" + "CREATE TABLE dbmem_content (" + " hash INTEGER PRIMARY KEY NOT NULL," + " path TEXT NOT NULL DEFAULT '' UNIQUE," + " value TEXT DEFAULT NULL," + " length INTEGER NOT NULL DEFAULT 0," + " context TEXT DEFAULT NULL," + " created_at INTEGER DEFAULT 0," + " last_accessed INTEGER DEFAULT 0);" + "CREATE TABLE dbmem_vault (" + " hash INTEGER NOT NULL," + " seq INTEGER NOT NULL," + " embedding BLOB NOT NULL," + " offset INTEGER NOT NULL," + " length INTEGER NOT NULL," + " PRIMARY KEY (hash, seq));" + "CREATE TRIGGER user_after_insert_dbmem_content " + "AFTER INSERT ON dbmem_content BEGIN SELECT 1; END;", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_memory_init(db, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(sync.cleanup_calls, 0); + ASSERT_EQ(sync.init_calls, 0); + ASSERT_EQ(sync.set_column_calls, 0); + ASSERT_EQ(sync.set_filter_calls, 0); + ASSERT_EQ(sync.clear_filter_calls, 0); + + sqlite3_int64 count = -1; + rc = exec_get_int(db, + "SELECT COUNT(*) FROM sqlite_master " + "WHERE type='table' AND name='cloudsync_table_settings';", + &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + sqlite3_close(db); +} + +TEST(sqlite_schema_init_repairs_stale_fts_hashes) { + sqlite3 *db = NULL; + int rc = sqlite3_open(":memory:", &db); + ASSERT_EQ(rc, SQLITE_OK); + + // Simulate a database first opened on a build without FTS5 during the + // hash migration: core tables are already TEXT, but the legacy FTS table + // still contains decimal hash strings. + rc = sqlite3_exec(db, + "CREATE TABLE dbmem_settings (key TEXT PRIMARY KEY, value TEXT);" + "CREATE TABLE dbmem_content (" + " hash TEXT PRIMARY KEY NOT NULL," + " path TEXT NOT NULL DEFAULT '' UNIQUE," + " value TEXT DEFAULT NULL," + " length INTEGER NOT NULL DEFAULT 0," + " context TEXT DEFAULT NULL," + " created_at INTEGER DEFAULT 0," + " last_accessed INTEGER DEFAULT 0);" + "CREATE TABLE dbmem_vault (" + " hash TEXT NOT NULL," + " seq INTEGER NOT NULL," + " embedding BLOB NOT NULL," + " offset INTEGER NOT NULL," + " length INTEGER NOT NULL," + " PRIMARY KEY (hash, seq));" + "CREATE VIRTUAL TABLE dbmem_vault_fts USING fts5 " + " (content, hash UNINDEXED, seq UNINDEXED, context UNINDEXED);" + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES" + " ('00000000000000ff', 'path_a', 'content a', 9, 'ctx_a', 1000);" + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES" + " ('00000000000000ff', 0, X'0000803f', 0, 9);" + "INSERT INTO dbmem_vault_fts (content, hash, seq, context) VALUES" + " ('content a', '255', 0, 'ctx_a');", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 joined_before = -1; + rc = exec_get_int(db, + "SELECT COUNT(*) " + "FROM dbmem_vault_fts AS f " + "JOIN dbmem_vault AS v ON f.hash = v.hash AND f.seq = v.seq;", + &joined_before); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(joined_before, 0); + + rc = sqlite3_memory_init(db, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + char hash_val[64]; + rc = exec_get_text(db, + "SELECT hash FROM dbmem_vault_fts WHERE seq=0;", + hash_val, sizeof(hash_val)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(hash_val, "00000000000000ff"); + + sqlite3_int64 joined_after = -1; + rc = exec_get_int(db, + "SELECT COUNT(*) " + "FROM dbmem_vault_fts AS f " + "JOIN dbmem_vault AS v ON f.hash = v.hash AND f.seq = v.seq;", + &joined_after); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(joined_after, 1); + + sqlite3_close(db); +} + +TEST(sqlite_schema_migration_preserves_user_schema_objects) { + sqlite3 *db = NULL; + int rc = sqlite3_open(":memory:", &db); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_exec(db, + "CREATE TABLE dbmem_settings (key TEXT PRIMARY KEY, value TEXT);" + "CREATE TABLE dbmem_content (" + " hash INTEGER PRIMARY KEY NOT NULL," + " path TEXT NOT NULL DEFAULT '' UNIQUE," + " value TEXT DEFAULT NULL," + " length INTEGER NOT NULL DEFAULT 0," + " context TEXT DEFAULT NULL," + " created_at INTEGER DEFAULT 0," + " last_accessed INTEGER DEFAULT 0);" + "CREATE TABLE dbmem_vault (" + " hash INTEGER NOT NULL," + " seq INTEGER NOT NULL," + " embedding BLOB NOT NULL," + " offset INTEGER NOT NULL," + " length INTEGER NOT NULL," + " PRIMARY KEY (hash, seq));" + "CREATE TABLE dbmem_cache (" + " text_hash INTEGER NOT NULL," + " provider TEXT NOT NULL," + " model TEXT NOT NULL," + " embedding BLOB NOT NULL," + " dimension INTEGER NOT NULL," + " PRIMARY KEY (text_hash, provider, model));" + "CREATE TABLE user_audit (hash TEXT);" + "CREATE INDEX idx_dbmem_content_context ON dbmem_content(context);" + "CREATE INDEX idx_dbmem_cache_provider ON dbmem_cache(provider);" + "CREATE TRIGGER trg_dbmem_vault_audit " + "AFTER INSERT ON dbmem_vault BEGIN " + " INSERT INTO user_audit(hash) VALUES (NEW.hash); " + "END;" + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES" + " (255, 'path_a', 'content a', 9, 'ctx_a', 1000);" + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES" + " (255, 0, X'0000803f', 0, 9);" + "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES" + " (255, 'test', 'model', X'0000803f', 1);" + "DELETE FROM user_audit;", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_memory_init(db, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 count = -1; + rc = exec_get_int(db, + "SELECT COUNT(*) FROM sqlite_master " + "WHERE type='index' AND name='idx_dbmem_content_context';", + &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = exec_get_int(db, + "SELECT COUNT(*) FROM sqlite_master " + "WHERE type='index' AND name='idx_dbmem_cache_provider';", + &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = exec_get_int(db, + "SELECT COUNT(*) FROM sqlite_master " + "WHERE type='trigger' AND name='trg_dbmem_vault_audit';", + &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = sqlite3_exec(db, + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES" + " ('00000000000000aa', 1, X'0000803f', 0, 4);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + char audit_hash[64]; + rc = exec_get_text(db, "SELECT hash FROM user_audit;", audit_hash, sizeof(audit_hash)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(audit_hash, "00000000000000aa"); + + sqlite3_close(db); +} + +TEST(sqlite_schema_migration_preserves_dependent_views_and_foreign_keys) { + sqlite3 *db = NULL; + int rc = sqlite3_open(":memory:", &db); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_exec(db, + "PRAGMA foreign_keys=ON;" + "CREATE TABLE dbmem_settings (key TEXT PRIMARY KEY, value TEXT);" + "CREATE TABLE dbmem_content (" + " hash INTEGER PRIMARY KEY NOT NULL," + " path TEXT NOT NULL DEFAULT '' UNIQUE," + " value TEXT DEFAULT NULL," + " length INTEGER NOT NULL DEFAULT 0," + " context TEXT DEFAULT NULL," + " created_at INTEGER DEFAULT 0," + " last_accessed INTEGER DEFAULT 0);" + "CREATE TABLE dbmem_vault (" + " hash INTEGER NOT NULL," + " seq INTEGER NOT NULL," + " embedding BLOB NOT NULL," + " offset INTEGER NOT NULL," + " length INTEGER NOT NULL," + " PRIMARY KEY (hash, seq));" + "CREATE VIEW user_content_view AS " + " SELECT path, context FROM dbmem_content;" + "CREATE TABLE user_content_refs (" + " content_hash INTEGER REFERENCES dbmem_content(hash));" + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES" + " (255, 'path_a', 'content a', 9, 'ctx_a', 1000);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_memory_init(db, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + char view_sql[256]; + rc = exec_get_text(db, + "SELECT sql FROM sqlite_master WHERE type='view' AND name='user_content_view';", + view_sql, sizeof(view_sql)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strstr(view_sql, "dbmem_content") != NULL); + ASSERT(strstr(view_sql, "_dbmem_content_old") == NULL); + + char fk_sql[256]; + rc = exec_get_text(db, + "SELECT sql FROM sqlite_master WHERE type='table' AND name='user_content_refs';", + fk_sql, sizeof(fk_sql)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strstr(fk_sql, "REFERENCES dbmem_content") != NULL); + ASSERT(strstr(fk_sql, "_dbmem_content_old") == NULL); + + char path[64]; + rc = exec_get_text(db, "SELECT path FROM user_content_view;", path, sizeof(path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(path, "path_a"); + + sqlite3_close(db); +} + TEST(sqlite_custom_provider_apikey_passed) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); @@ -2521,6 +3249,15 @@ int main(int argc, char *argv[]) { printf("\nSearch oversampling tests:\n"); RUN_TEST(sqlite_search_oversample_setting); + printf("\nSchema migration tests:\n"); + RUN_TEST(sqlite_schema_migration); + RUN_TEST(sqlite_schema_migration_preserves_cloudsync_filter); + RUN_TEST(sqlite_schema_migration_requires_cloudsync_when_synced); + RUN_TEST(sqlite_schema_migration_ignores_user_triggers); + RUN_TEST(sqlite_schema_init_repairs_stale_fts_hashes); + RUN_TEST(sqlite_schema_migration_preserves_user_schema_objects); + RUN_TEST(sqlite_schema_migration_preserves_dependent_views_and_foreign_keys); + printf("\nCustom provider tests:\n"); RUN_TEST(sqlite_custom_provider_register); RUN_TEST(sqlite_custom_provider_set_model); From 7f9141b98898409497b586f95a72e6a59f4d535b Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Fri, 17 Apr 2026 08:40:08 -0600 Subject: [PATCH 2/2] docs(sync): update dbmem_content schema to TEXT hash --- test/sync/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sync/README.md b/test/sync/README.md index 7078958..73c0e1a 100644 --- a/test/sync/README.md +++ b/test/sync/README.md @@ -62,7 +62,7 @@ The sync layer routes changes through a [SQLiteCloud](https://sqlitecloud.io/) m 3. **Create the memory table** — connect to your database and run: ```sql CREATE TABLE IF NOT EXISTS dbmem_content ( - hash INTEGER PRIMARY KEY NOT NULL, + hash TEXT PRIMARY KEY NOT NULL, path TEXT NOT NULL DEFAULT '' UNIQUE, value TEXT DEFAULT NULL, length INTEGER NOT NULL DEFAULT 0,