Mercurial
view mrjunejune/conversation_store.c @ 273:e02e2036ef84 default tip
add Layer 2 JRPG component system
Add reusable content and window modals, an isolated component sandbox, shared cyberpunk scroll areas, production-safe cache freshness, and server-rendered JRPG panel state.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sat, 08 Aug 2026 02:08:08 -0700 |
| parents | 04fee26ecce0 |
| children |
line wrap: on
line source
#include "mrjunejune/conversation_store.h" #include "deita/deita.h" #include <fcntl.h> #include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> struct Conversation_Store { Deita_Connection *p_connection; pthread_mutex_t mutex; }; boolean Conversation_Store_Generate_UUID(char output[37]) { uint8 bytes[16]; int fd = open("/dev/urandom", O_RDONLY); if (fd < 0) return FALSE; size_t offset = 0; while (offset < sizeof(bytes)) { ssize_t amount = read(fd, bytes + offset, sizeof(bytes) - offset); if (amount <= 0) { close(fd); return FALSE; } offset += (size_t)amount; } close(fd); bytes[6] = (uint8)((bytes[6] & 0x0f) | 0x40); bytes[8] = (uint8)((bytes[8] & 0x3f) | 0x80); snprintf( output, 37, "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-" "%02x%02x%02x%02x%02x%02x", bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]); return TRUE; } static char *Conversation_Store_Copy_Text( const char *value, Dowa_Arena *p_arena) { const char *source = value ? value : ""; size_t length = strlen(source); char *copy = Dowa_Arena_Allocate(p_arena, length + 1); if (!copy) return NULL; memcpy(copy, source, length + 1); return copy; } static boolean Conversation_Store_Exists_Locked( Conversation_Store *p_store, const char *conversation_id) { Dowa_Arena *p_arena = Dowa_Arena_Create(1024); if (!p_arena) return FALSE; const char *parameters[] = {conversation_id}; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT 1 FROM conversations WHERE id = ? AND status != 'deleted'", 1, parameters, p_arena); boolean exists = p_result && Deita_Result_Set_Next(p_result); if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); return exists; } static Conversation_Store_Result Conversation_Store_Rollback( Conversation_Store *p_store, Conversation_Store_Result result) { Deita_Query_Execute_Update(p_store->p_connection, "ROLLBACK"); return result; } /* ------------------------------------------------------------------ */ /* Schema migration helpers (called from Conversation_Store_Create) */ /* ------------------------------------------------------------------ */ static const char *Conversation_Store_Owner_Kind_String( Conversation_Owner_Kind kind) { switch (kind) { case CONVERSATION_OWNER_KIND_USER: return "user"; case CONVERSATION_OWNER_KIND_GUEST: return "guest"; case CONVERSATION_OWNER_KIND_LEGACY: return "legacy"; default: return "legacy"; } } /* Returns TRUE if column exists in table (mutex must NOT be held). */ static boolean Conversation_Store_Column_Exists( Conversation_Store *p_store, const char *table_name, const char *column_name) { char sql[256]; snprintf(sql, sizeof(sql), "SELECT 1 FROM pragma_table_info('%s') WHERE name = '%s'", table_name, column_name); Dowa_Arena *p_arena = Dowa_Arena_Create(512); if (!p_arena) return FALSE; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(p_store->p_connection, sql, 0, NULL, p_arena); boolean exists = p_result && Deita_Result_Set_Next(p_result); if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); return exists; } /* * Migration 1: add owner_kind / owner_id columns and listing index. * Existing rows become owner_kind='legacy', owner_id=NULL. * Safe to call on a database that was created by new code (idempotent). */ static boolean Conversation_Store_Apply_Migration_1( Conversation_Store *p_store) { /* Check migrations ledger */ Dowa_Arena *p_arena = Dowa_Arena_Create(512); if (!p_arena) return FALSE; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT 1 FROM conversation_schema_migrations WHERE version = 1", 0, NULL, p_arena); boolean already_done = p_result && Deita_Result_Set_Next(p_result); if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); if (already_done) return TRUE; if (!Conversation_Store_Column_Exists(p_store, "conversations", "owner_kind")) { if (Deita_Query_Execute_Update( p_store->p_connection, "ALTER TABLE conversations ADD COLUMN owner_kind TEXT " "NOT NULL DEFAULT 'legacy'") < 0) return FALSE; } if (!Conversation_Store_Column_Exists(p_store, "conversations", "owner_id")) { if (Deita_Query_Execute_Update( p_store->p_connection, "ALTER TABLE conversations ADD COLUMN owner_id TEXT") < 0) return FALSE; } if (Deita_Query_Execute_Update( p_store->p_connection, "CREATE INDEX IF NOT EXISTS idx_conversations_owner_listing " "ON conversations(owner_kind, owner_id, updated_at DESC, id)") < 0) return FALSE; if (Deita_Query_Execute_Update( p_store->p_connection, "INSERT OR IGNORE INTO conversation_schema_migrations (version) " "VALUES (1)") < 0) return FALSE; return TRUE; } /* * Migration 2: create the guest-to-user transfer mapping table. * Records a permanent mapping from guest_id → user_id set at transfer time. * Used by Create_Owned to redirect stale guest creates to the mapped user. */ static boolean Conversation_Store_Apply_Migration_2( Conversation_Store *p_store) { Dowa_Arena *p_arena = Dowa_Arena_Create(512); if (!p_arena) return FALSE; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT 1 FROM conversation_schema_migrations WHERE version = 2", 0, NULL, p_arena); boolean already_done = p_result && Deita_Result_Set_Next(p_result); if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); if (already_done) return TRUE; if (Deita_Query_Execute_Update( p_store->p_connection, "CREATE TABLE IF NOT EXISTS conversation_guest_transfers (" "guest_id TEXT PRIMARY KEY," "user_id TEXT NOT NULL," "transferred_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))" ")") < 0) return FALSE; if (Deita_Query_Execute_Update( p_store->p_connection, "INSERT OR IGNORE INTO conversation_schema_migrations (version) " "VALUES (2)") < 0) return FALSE; return TRUE; } /* * Migration 3: recreate the owner listing index with id DESC so that * keyset pagination is stable when updated_at values collide. * Drops and recreates the index atomically from the migration ledger's * perspective; safe to run on any database that has migration 1 applied. */ static boolean Conversation_Store_Apply_Migration_3( Conversation_Store *p_store) { Dowa_Arena *p_arena = Dowa_Arena_Create(512); if (!p_arena) return FALSE; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT 1 FROM conversation_schema_migrations WHERE version = 3", 0, NULL, p_arena); boolean already_done = p_result && Deita_Result_Set_Next(p_result); if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); if (already_done) return TRUE; if (Deita_Query_Execute_Update( p_store->p_connection, "DROP INDEX IF EXISTS idx_conversations_owner_listing") < 0) return FALSE; if (Deita_Query_Execute_Update( p_store->p_connection, "CREATE INDEX IF NOT EXISTS idx_conversations_owner_listing " "ON conversations(owner_kind, owner_id, updated_at DESC, id DESC)") < 0) return FALSE; if (Deita_Query_Execute_Update( p_store->p_connection, "INSERT OR IGNORE INTO conversation_schema_migrations (version) " "VALUES (3)") < 0) return FALSE; return TRUE; } Conversation_Store *Conversation_Store_Create(const char *database_path) { if (!database_path) return NULL; Conversation_Store *p_store = calloc(1, sizeof(*p_store)); if (!p_store) return NULL; p_store->p_connection = Deita_Connection_Create( DEITA_DATABASE_TYPE_SQLITE3, database_path); if (!p_store->p_connection || !Deita_Connection_Is_Open(p_store->p_connection)) { if (p_store->p_connection) Deita_Connection_Close(p_store->p_connection); free(p_store); return NULL; } if (pthread_mutex_init(&p_store->mutex, NULL) != 0) { Deita_Connection_Close(p_store->p_connection); free(p_store); return NULL; } const char *schema = "PRAGMA foreign_keys = ON;" "PRAGMA journal_mode = WAL;" "CREATE TABLE IF NOT EXISTS conversations (" "id TEXT PRIMARY KEY," "copilot_session_id TEXT NOT NULL UNIQUE," "title TEXT NOT NULL DEFAULT ''," "status TEXT NOT NULL DEFAULT 'active'," "created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," "updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))" ");" "CREATE TABLE IF NOT EXISTS conversation_turns (" "id INTEGER PRIMARY KEY AUTOINCREMENT," "conversation_id TEXT NOT NULL," "sequence INTEGER NOT NULL," "role TEXT NOT NULL," "content TEXT NOT NULL DEFAULT ''," "status TEXT NOT NULL," "request_id TEXT," "error_message TEXT," "input_tokens INTEGER NOT NULL DEFAULT 0," "output_tokens INTEGER NOT NULL DEFAULT 0," "created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," "completed_at INTEGER," "FOREIGN KEY(conversation_id) REFERENCES conversations(id) " "ON DELETE CASCADE," "UNIQUE(conversation_id, sequence)" ");" "CREATE UNIQUE INDEX IF NOT EXISTS " "idx_conversation_active_request " "ON conversation_turns(conversation_id) " "WHERE role = 'assistant' AND status = 'active';" "CREATE INDEX IF NOT EXISTS idx_conversations_updated " "ON conversations(updated_at DESC);" "CREATE INDEX IF NOT EXISTS idx_turns_conversation_sequence " "ON conversation_turns(conversation_id, sequence);" "CREATE TABLE IF NOT EXISTS conversation_schema_migrations (" "version INTEGER PRIMARY KEY," "applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))" ");"; if (Deita_Query_Execute_Update(p_store->p_connection, schema) < 0) { Conversation_Store_Destroy(p_store); return NULL; } if (!Conversation_Store_Apply_Migration_1(p_store)) { Conversation_Store_Destroy(p_store); return NULL; } if (!Conversation_Store_Apply_Migration_2(p_store)) { Conversation_Store_Destroy(p_store); return NULL; } if (!Conversation_Store_Apply_Migration_3(p_store)) { Conversation_Store_Destroy(p_store); return NULL; } if (Deita_Query_Execute_Update( p_store->p_connection, "UPDATE conversation_turns " "SET status = 'failed', " "error_message = 'Interrupted by server restart', " "completed_at = strftime('%s','now') " "WHERE role = 'assistant' AND status = 'active'") < 0) { Conversation_Store_Destroy(p_store); return NULL; } return p_store; } void Conversation_Store_Destroy(Conversation_Store *p_store) { if (!p_store) return; if (p_store->p_connection) Deita_Connection_Close(p_store->p_connection); pthread_mutex_destroy(&p_store->mutex); free(p_store); } Conversation_Store_Result Conversation_Store_Create_Conversation( Conversation_Store *p_store, const char *title, char output_id[37]) { if (!p_store || !output_id || !Conversation_Store_Generate_UUID(output_id)) return CONVERSATION_STORE_ERROR; const char *parameters[] = { output_id, output_id, title ? title : "", }; pthread_mutex_lock(&p_store->mutex); int32 result = Deita_Query_Execute_Update_Prepared( p_store->p_connection, "INSERT INTO conversations (id, copilot_session_id, title) " "VALUES (?, ?, ?)", 3, parameters); pthread_mutex_unlock(&p_store->mutex); return result < 0 ? CONVERSATION_STORE_ERROR : CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_Get( Conversation_Store *p_store, const char *conversation_id, Conversation_Record *p_record, Dowa_Arena *p_arena) { if (!p_store || !conversation_id || !p_record || !p_arena) return CONVERSATION_STORE_ERROR; memset(p_record, 0, sizeof(*p_record)); const char *parameters[] = {conversation_id}; pthread_mutex_lock(&p_store->mutex); Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT id, copilot_session_id, title, status, created_at, updated_at " "FROM conversations WHERE id = ? AND status != 'deleted'", 1, parameters, p_arena); if (!p_result || !Deita_Result_Set_Next(p_result)) { if (p_result) Deita_Result_Set_Free(p_result); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_NOT_FOUND; } p_record->id = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 0), p_arena); p_record->copilot_session_id = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 1), p_arena); p_record->title = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 2), p_arena); p_record->status = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 3), p_arena); p_record->created_at = Deita_Result_Set_Get_Integer(p_result, 4); p_record->updated_at = Deita_Result_Set_Get_Integer(p_result, 5); Deita_Result_Set_Free(p_result); p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT id, sequence, role, content, status, request_id, " "error_message, input_tokens, output_tokens, created_at, completed_at " "FROM (SELECT id, sequence, role, content, status, request_id, " "error_message, input_tokens, output_tokens, created_at, completed_at " "FROM conversation_turns WHERE conversation_id = ? " "ORDER BY sequence DESC LIMIT 20) ORDER BY sequence", 1, parameters, p_arena); if (!p_result) { pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } while (p_result && Deita_Result_Set_Next(p_result)) { Conversation_Turn turn = {0}; turn.id = Deita_Result_Set_Get_Integer(p_result, 0); turn.sequence = Deita_Result_Set_Get_Integer(p_result, 1); turn.role = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 2), p_arena); turn.content = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 3), p_arena); turn.status = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 4), p_arena); turn.request_id = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 5), p_arena); turn.error_message = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 6), p_arena); turn.input_tokens = Deita_Result_Set_Get_Integer(p_result, 7); turn.output_tokens = Deita_Result_Set_Get_Integer(p_result, 8); turn.created_at = Deita_Result_Set_Get_Integer(p_result, 9); turn.completed_at = Deita_Result_Set_Get_Integer(p_result, 10); Dowa_Array_Push_Arena(p_record->turns, turn, p_arena); } boolean turns_error = Deita_Result_Set_Has_Error(p_result); if (p_result) Deita_Result_Set_Free(p_result); pthread_mutex_unlock(&p_store->mutex); return turns_error ? CONVERSATION_STORE_ERROR : CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_Update_Title( Conversation_Store *p_store, const char *conversation_id, const char *title) { if (!p_store || !conversation_id || !title) return CONVERSATION_STORE_ERROR; const char *parameters[] = {title, conversation_id}; pthread_mutex_lock(&p_store->mutex); int32 result = Deita_Query_Execute_Update_Prepared( p_store->p_connection, "UPDATE conversations SET title = ?, " "updated_at = strftime('%s','now') " "WHERE id = ? AND status != 'deleted'", 2, parameters); pthread_mutex_unlock(&p_store->mutex); if (result < 0) return CONVERSATION_STORE_ERROR; return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_Delete( Conversation_Store *p_store, const char *conversation_id) { if (!p_store || !conversation_id) return CONVERSATION_STORE_ERROR; const char *parameters[] = {conversation_id}; pthread_mutex_lock(&p_store->mutex); int32 result = Deita_Query_Execute_Update_Prepared( p_store->p_connection, "DELETE FROM conversations WHERE id = ?", 1, parameters); pthread_mutex_unlock(&p_store->mutex); if (result < 0) return CONVERSATION_STORE_ERROR; return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_Begin_Turn( Conversation_Store *p_store, const char *conversation_id, const char *request_id, const char *prompt) { if (!p_store || !conversation_id || !request_id || !prompt) return CONVERSATION_STORE_ERROR; pthread_mutex_lock(&p_store->mutex); if (Deita_Query_Execute_Update( p_store->p_connection, "BEGIN IMMEDIATE") < 0) { pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } if (!Conversation_Store_Exists_Locked(p_store, conversation_id)) { Conversation_Store_Rollback( p_store, CONVERSATION_STORE_NOT_FOUND); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_NOT_FOUND; } Dowa_Arena *p_arena = Dowa_Arena_Create(2048); const char *parameters[] = {conversation_id}; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT COALESCE(MAX(sequence), 0), " "SUM(CASE WHEN role = 'assistant' AND status = 'active' " "THEN 1 ELSE 0 END) " "FROM conversation_turns WHERE conversation_id = ?", 1, parameters, p_arena); if (!p_result || !Deita_Result_Set_Next(p_result)) { if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } int64 next_sequence = Deita_Result_Set_Get_Integer(p_result, 0) + 1; int64 active_count = Deita_Result_Set_Get_Integer(p_result, 1); Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); if (active_count > 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_CONFLICT; } char user_sequence[32]; char assistant_sequence[32]; snprintf(user_sequence, sizeof(user_sequence), "%lld", (long long)next_sequence); snprintf(assistant_sequence, sizeof(assistant_sequence), "%lld", (long long)(next_sequence + 1)); const char *user_parameters[] = { conversation_id, user_sequence, prompt, request_id, }; if (Deita_Query_Execute_Update_Prepared( p_store->p_connection, "INSERT INTO conversation_turns " "(conversation_id, sequence, role, content, status, request_id, " "completed_at) VALUES (?, ?, 'user', ?, 'complete', ?, " "strftime('%s','now'))", 4, user_parameters) < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } const char *assistant_parameters[] = { conversation_id, assistant_sequence, request_id, }; if (Deita_Query_Execute_Update_Prepared( p_store->p_connection, "INSERT INTO conversation_turns " "(conversation_id, sequence, role, status, request_id) " "VALUES (?, ?, 'assistant', 'active', ?)", 3, assistant_parameters) < 0 || Deita_Query_Execute_Update_Prepared( p_store->p_connection, "UPDATE conversations SET updated_at = strftime('%s','now') " "WHERE id = ?", 1, parameters) < 0 || Deita_Query_Execute_Update( p_store->p_connection, "COMMIT") < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_Complete_Turn( Conversation_Store *p_store, const char *conversation_id, const char *request_id, const char *content, int64 input_tokens, int64 output_tokens) { if (!p_store || !conversation_id || !request_id || !content) return CONVERSATION_STORE_ERROR; char input_value[32]; char output_value[32]; snprintf(input_value, sizeof(input_value), "%lld", (long long)input_tokens); snprintf(output_value, sizeof(output_value), "%lld", (long long)output_tokens); const char *parameters[] = { content, input_value, output_value, conversation_id, request_id, }; pthread_mutex_lock(&p_store->mutex); int32 result = Deita_Query_Execute_Update_Prepared( p_store->p_connection, "UPDATE conversation_turns SET content = ?, status = 'complete', " "input_tokens = ?, output_tokens = ?, " "completed_at = strftime('%s','now') " "WHERE conversation_id = ? AND request_id = ? " "AND role = 'assistant' AND status = 'active'", 5, parameters); pthread_mutex_unlock(&p_store->mutex); if (result < 0) return CONVERSATION_STORE_ERROR; return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_Fail_Turn( Conversation_Store *p_store, const char *conversation_id, const char *request_id, const char *error_message, boolean aborted) { if (!p_store || !conversation_id || !request_id) return CONVERSATION_STORE_ERROR; const char *parameters[] = { aborted ? "aborted" : "failed", error_message ? error_message : "", conversation_id, request_id, }; pthread_mutex_lock(&p_store->mutex); int32 result = Deita_Query_Execute_Update_Prepared( p_store->p_connection, "UPDATE conversation_turns SET status = ?, error_message = ?, " "completed_at = strftime('%s','now') " "WHERE conversation_id = ? AND request_id = ? " "AND role = 'assistant' AND status = 'active'", 4, parameters); pthread_mutex_unlock(&p_store->mutex); if (result < 0) return CONVERSATION_STORE_ERROR; return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK; } /* ------------------------------------------------------------------ */ /* Owner-aware APIs */ /* ------------------------------------------------------------------ */ Conversation_Store_Result Conversation_Store_Create_Owned( Conversation_Store *p_store, const char *title, const Conversation_Owner *p_owner, char output_id[37]) { if (!p_store || !p_owner || !output_id) return CONVERSATION_STORE_ERROR; if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || p_owner->id[0] == '\0') return CONVERSATION_STORE_ERROR; if (!Conversation_Store_Generate_UUID(output_id)) return CONVERSATION_STORE_ERROR; /* Resolved owner fields — may be overridden by transfer mapping below */ const char *resolved_kind = Conversation_Store_Owner_Kind_String(p_owner->kind); char resolved_id[37]; strncpy(resolved_id, p_owner->id, 36); resolved_id[36] = '\0'; pthread_mutex_lock(&p_store->mutex); if (Deita_Query_Execute_Update( p_store->p_connection, "BEGIN IMMEDIATE") < 0) { pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } /* For guest owners: check transfer mapping within the same transaction. * If the guest was transferred to a user, assign to that user instead. */ if (p_owner->kind == CONVERSATION_OWNER_KIND_GUEST) { Dowa_Arena *p_arena = Dowa_Arena_Create(512); if (!p_arena) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } const char *check_params[] = {p_owner->id}; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?", 1, check_params, p_arena); if (p_result && Deita_Result_Set_Next(p_result)) { const char *mapped_user = Deita_Result_Set_Get_Text(p_result, 0); if (mapped_user && mapped_user[0] != '\0') { strncpy(resolved_id, mapped_user, 36); resolved_id[36] = '\0'; resolved_kind = "user"; } } if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); } const char *parameters[] = { output_id, output_id, title ? title : "", resolved_kind, resolved_id, }; int32 result = Deita_Query_Execute_Update_Prepared( p_store->p_connection, "INSERT INTO conversations " "(id, copilot_session_id, title, owner_kind, owner_id) " "VALUES (?, ?, ?, ?, ?)", 5, parameters); if (result < 0 || Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } pthread_mutex_unlock(&p_store->mutex); return result < 0 ? CONVERSATION_STORE_ERROR : CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_Get_Owned( Conversation_Store *p_store, const char *conversation_id, const Conversation_Owner *p_owner, Conversation_Record *p_record, Dowa_Arena *p_arena) { if (!p_store || !conversation_id || !p_owner || !p_record || !p_arena) return CONVERSATION_STORE_ERROR; if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || p_owner->id[0] == '\0') return CONVERSATION_STORE_ERROR; memset(p_record, 0, sizeof(*p_record)); const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind); const char *parameters[] = {conversation_id, kind_str, p_owner->id}; pthread_mutex_lock(&p_store->mutex); Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT id, copilot_session_id, title, status, created_at, updated_at " "FROM conversations " "WHERE id = ? AND status != 'deleted' " "AND owner_kind = ? AND owner_id = ?", 3, parameters, p_arena); if (!p_result || !Deita_Result_Set_Next(p_result)) { if (p_result) Deita_Result_Set_Free(p_result); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_NOT_FOUND; } p_record->id = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 0), p_arena); p_record->copilot_session_id = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 1), p_arena); p_record->title = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 2), p_arena); p_record->status = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 3), p_arena); p_record->created_at = Deita_Result_Set_Get_Integer(p_result, 4); p_record->updated_at = Deita_Result_Set_Get_Integer(p_result, 5); Deita_Result_Set_Free(p_result); const char *turn_params[] = {conversation_id}; p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT id, sequence, role, content, status, request_id, " "error_message, input_tokens, output_tokens, created_at, completed_at " "FROM (SELECT id, sequence, role, content, status, request_id, " "error_message, input_tokens, output_tokens, created_at, completed_at " "FROM conversation_turns WHERE conversation_id = ? " "ORDER BY sequence DESC LIMIT 20) ORDER BY sequence", 1, turn_params, p_arena); if (!p_result) { pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } while (Deita_Result_Set_Next(p_result)) { Conversation_Turn turn = {0}; turn.id = Deita_Result_Set_Get_Integer(p_result, 0); turn.sequence = Deita_Result_Set_Get_Integer(p_result, 1); turn.role = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 2), p_arena); turn.content = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 3), p_arena); turn.status = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 4), p_arena); turn.request_id = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 5), p_arena); turn.error_message = Conversation_Store_Copy_Text( Deita_Result_Set_Get_Text(p_result, 6), p_arena); turn.input_tokens = Deita_Result_Set_Get_Integer(p_result, 7); turn.output_tokens = Deita_Result_Set_Get_Integer(p_result, 8); turn.created_at = Deita_Result_Set_Get_Integer(p_result, 9); turn.completed_at = Deita_Result_Set_Get_Integer(p_result, 10); Dowa_Array_Push_Arena(p_record->turns, turn, p_arena); } boolean turns_error = Deita_Result_Set_Has_Error(p_result); Deita_Result_Set_Free(p_result); pthread_mutex_unlock(&p_store->mutex); return turns_error ? CONVERSATION_STORE_ERROR : CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_Update_Title_Owned( Conversation_Store *p_store, const char *conversation_id, const Conversation_Owner *p_owner, const char *title) { if (!p_store || !conversation_id || !p_owner || !title) return CONVERSATION_STORE_ERROR; if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || p_owner->id[0] == '\0') return CONVERSATION_STORE_ERROR; const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind); const char *parameters[] = {title, conversation_id, kind_str, p_owner->id}; pthread_mutex_lock(&p_store->mutex); int32 result = Deita_Query_Execute_Update_Prepared( p_store->p_connection, "UPDATE conversations SET title = ?, " "updated_at = strftime('%s','now') " "WHERE id = ? AND status != 'deleted' " "AND owner_kind = ? AND owner_id = ?", 4, parameters); pthread_mutex_unlock(&p_store->mutex); if (result < 0) return CONVERSATION_STORE_ERROR; return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_Delete_Owned( Conversation_Store *p_store, const char *conversation_id, const Conversation_Owner *p_owner) { if (!p_store || !conversation_id || !p_owner) return CONVERSATION_STORE_ERROR; if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || p_owner->id[0] == '\0') return CONVERSATION_STORE_ERROR; const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind); const char *parameters[] = {conversation_id, kind_str, p_owner->id}; pthread_mutex_lock(&p_store->mutex); int32 result = Deita_Query_Execute_Update_Prepared( p_store->p_connection, "DELETE FROM conversations WHERE id = ? " "AND owner_kind = ? AND owner_id = ?", 3, parameters); pthread_mutex_unlock(&p_store->mutex); if (result < 0) return CONVERSATION_STORE_ERROR; return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK; } /* Checks conversation ownership without loading turns (called under mutex). */ static boolean Conversation_Store_Owns_Locked( Conversation_Store *p_store, const char *conversation_id, const Conversation_Owner *p_owner) { const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind); const char *parameters[] = {conversation_id, kind_str, p_owner->id}; Dowa_Arena *p_arena = Dowa_Arena_Create(1024); if (!p_arena) return FALSE; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT 1 FROM conversations " "WHERE id = ? AND status != 'deleted' " "AND owner_kind = ? AND owner_id = ?", 3, parameters, p_arena); boolean owns = p_result && Deita_Result_Set_Next(p_result); if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); return owns; } Conversation_Store_Result Conversation_Store_Begin_Turn_Owned( Conversation_Store *p_store, const char *conversation_id, const Conversation_Owner *p_owner, const char *request_id, const char *prompt) { if (!p_store || !conversation_id || !p_owner || !request_id || !prompt) return CONVERSATION_STORE_ERROR; if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || p_owner->id[0] == '\0') return CONVERSATION_STORE_ERROR; pthread_mutex_lock(&p_store->mutex); if (Deita_Query_Execute_Update( p_store->p_connection, "BEGIN IMMEDIATE") < 0) { pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } if (!Conversation_Store_Owns_Locked(p_store, conversation_id, p_owner)) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_NOT_FOUND); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_NOT_FOUND; } Dowa_Arena *p_arena = Dowa_Arena_Create(2048); const char *seq_params[] = {conversation_id}; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT COALESCE(MAX(sequence), 0), " "SUM(CASE WHEN role = 'assistant' AND status = 'active' " "THEN 1 ELSE 0 END) " "FROM conversation_turns WHERE conversation_id = ?", 1, seq_params, p_arena); if (!p_result || !Deita_Result_Set_Next(p_result)) { if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } int64 next_sequence = Deita_Result_Set_Get_Integer(p_result, 0) + 1; int64 active_count = Deita_Result_Set_Get_Integer(p_result, 1); Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); if (active_count > 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_CONFLICT; } char user_sequence[32]; char assistant_sequence[32]; snprintf(user_sequence, sizeof(user_sequence), "%lld", (long long)next_sequence); snprintf(assistant_sequence, sizeof(assistant_sequence), "%lld", (long long)(next_sequence + 1)); const char *user_params[] = { conversation_id, user_sequence, prompt, request_id, }; if (Deita_Query_Execute_Update_Prepared( p_store->p_connection, "INSERT INTO conversation_turns " "(conversation_id, sequence, role, content, status, request_id, " "completed_at) VALUES (?, ?, 'user', ?, 'complete', ?, " "strftime('%s','now'))", 4, user_params) < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } const char *asst_params[] = {conversation_id, assistant_sequence, request_id}; const char *conv_params[] = {conversation_id}; if (Deita_Query_Execute_Update_Prepared( p_store->p_connection, "INSERT INTO conversation_turns " "(conversation_id, sequence, role, status, request_id) " "VALUES (?, ?, 'assistant', 'active', ?)", 3, asst_params) < 0 || Deita_Query_Execute_Update_Prepared( p_store->p_connection, "UPDATE conversations SET updated_at = strftime('%s','now') " "WHERE id = ?", 1, conv_params) < 0 || Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_List( Conversation_Store *p_store, const Conversation_Owner *p_owner, int64 cursor_updated_at, const char *cursor_id, int32 limit, Conversation_Summary **pp_summaries, int32 *p_count, Dowa_Arena *p_arena) { if (!p_store || !p_owner || !pp_summaries || !p_count || !p_arena) return CONVERSATION_STORE_ERROR; *pp_summaries = NULL; *p_count = 0; /* Legacy conversations are never listed */ if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || p_owner->id[0] == '\0') return CONVERSATION_STORE_OK; if (limit < 1) limit = 1; if (limit > 50) limit = 50; const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind); char limit_str[16]; snprintf(limit_str, sizeof(limit_str), "%d", limit); Deita_Result_Set *p_result; pthread_mutex_lock(&p_store->mutex); if (cursor_updated_at > 0 && cursor_id && cursor_id[0] != '\0') { char ts_str[32]; snprintf(ts_str, sizeof(ts_str), "%lld", (long long)cursor_updated_at); const char *params[] = { kind_str, p_owner->id, ts_str, ts_str, cursor_id, limit_str, }; p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT id, title, status, created_at, updated_at, " "(SELECT COUNT(*) FROM conversation_turns " " WHERE conversation_id = conversations.id) AS turn_count, " "(SELECT SUBSTR(content, 1, 201) FROM conversation_turns " " WHERE conversation_id = conversations.id " " ORDER BY sequence DESC LIMIT 1) AS last_msg " "FROM conversations " "WHERE owner_kind = ? AND owner_id = ? AND status != 'deleted' " "AND (updated_at < ? OR (updated_at = ? AND id < ?)) " "ORDER BY updated_at DESC, id DESC LIMIT ?", 6, params, p_arena); } else { const char *params[] = {kind_str, p_owner->id, limit_str}; p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT id, title, status, created_at, updated_at, " "(SELECT COUNT(*) FROM conversation_turns " " WHERE conversation_id = conversations.id) AS turn_count, " "(SELECT SUBSTR(content, 1, 201) FROM conversation_turns " " WHERE conversation_id = conversations.id " " ORDER BY sequence DESC LIMIT 1) AS last_msg " "FROM conversations " "WHERE owner_kind = ? AND owner_id = ? AND status != 'deleted' " "ORDER BY updated_at DESC, id DESC LIMIT ?", 3, params, p_arena); } if (!p_result) { pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } Conversation_Summary *summaries = NULL; while (Deita_Result_Set_Next(p_result)) { Conversation_Summary s = {0}; s.id = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 0), p_arena); s.title = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 1), p_arena); s.status = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 2), p_arena); s.created_at = Deita_Result_Set_Get_Integer(p_result, 3); s.updated_at = Deita_Result_Set_Get_Integer(p_result, 4); s.turn_count = Deita_Result_Set_Get_Integer(p_result, 5); const char *last_msg_raw = Deita_Result_Set_Get_Text(p_result, 6); if (last_msg_raw && last_msg_raw[0] != '\0') { /* Truncate to 200 chars maximum */ size_t msg_len = strlen(last_msg_raw); if (msg_len > 200) msg_len = 200; char *preview = Dowa_Arena_Allocate(p_arena, msg_len + 1); if (preview) { memcpy(preview, last_msg_raw, msg_len); preview[msg_len] = '\0'; } s.last_message_preview = preview; } else { s.last_message_preview = Conversation_Store_Copy_Text("", p_arena); } Dowa_Array_Push_Arena(summaries, s, p_arena); } boolean has_error = Deita_Result_Set_Has_Error(p_result); Deita_Result_Set_Free(p_result); pthread_mutex_unlock(&p_store->mutex); if (has_error) return CONVERSATION_STORE_ERROR; *pp_summaries = summaries; *p_count = (int32)Dowa_Array_Length(summaries); return CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_Claim_Legacy( Conversation_Store *p_store, const char *conversation_id, const char *user_id) { if (!p_store || !conversation_id || !user_id || user_id[0] == '\0') return CONVERSATION_STORE_ERROR; pthread_mutex_lock(&p_store->mutex); if (Deita_Query_Execute_Update( p_store->p_connection, "BEGIN IMMEDIATE") < 0) { pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } Dowa_Arena *p_arena = Dowa_Arena_Create(512); if (!p_arena) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } const char *check_params[] = {conversation_id}; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT owner_kind FROM conversations " "WHERE id = ? AND status != 'deleted'", 1, check_params, p_arena); boolean found = p_result && Deita_Result_Set_Next(p_result); const char *existing_kind = found ? Deita_Result_Set_Get_Text(p_result, 0) : NULL; boolean is_claimable = existing_kind && strcmp(existing_kind, "legacy") == 0; if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); if (!found) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_NOT_FOUND); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_NOT_FOUND; } if (!is_claimable) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_CONFLICT; } const char *update_params[] = {user_id, conversation_id}; int32 updated = Deita_Query_Execute_Update_Prepared( p_store->p_connection, "UPDATE conversations SET owner_kind = 'user', owner_id = ?, " "updated_at = strftime('%s','now') " "WHERE id = ? AND owner_kind = 'legacy'", 2, update_params); if (updated < 0 || Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } pthread_mutex_unlock(&p_store->mutex); return updated > 0 ? CONVERSATION_STORE_OK : CONVERSATION_STORE_NOT_FOUND; } Conversation_Store_Result Conversation_Store_Transfer_Guest_To_User( Conversation_Store *p_store, const char *guest_id, const char *user_id) { if (!p_store || !guest_id || !user_id || guest_id[0] == '\0' || user_id[0] == '\0') return CONVERSATION_STORE_ERROR; pthread_mutex_lock(&p_store->mutex); if (Deita_Query_Execute_Update( p_store->p_connection, "BEGIN IMMEDIATE") < 0) { pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } /* Check for an existing mapping for this guest_id */ Dowa_Arena *p_arena = Dowa_Arena_Create(512); if (!p_arena) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } const char *check_params[] = {guest_id}; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?", 1, check_params, p_arena); boolean mapping_exists = p_result && Deita_Result_Set_Next(p_result); const char *existing_user = mapping_exists ? Deita_Result_Set_Get_Text(p_result, 0) : NULL; boolean same_user = mapping_exists && existing_user && strcmp(existing_user, user_id) == 0; boolean conflict = mapping_exists && !same_user; if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); if (conflict) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_CONFLICT; } if (!mapping_exists) { /* Record the mapping so future Create_Owned for this guest uses user */ const char *map_params[] = {guest_id, user_id}; if (Deita_Query_Execute_Update_Prepared( p_store->p_connection, "INSERT OR IGNORE INTO conversation_guest_transfers " "(guest_id, user_id) VALUES (?, ?)", 2, map_params) < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } /* Transfer existing conversations from guest to user */ const char *transfer_params[] = {user_id, guest_id}; if (Deita_Query_Execute_Update_Prepared( p_store->p_connection, "UPDATE conversations SET owner_kind = 'user', owner_id = ?, " "updated_at = strftime('%s','now') " "WHERE owner_kind = 'guest' AND owner_id = ?", 2, transfer_params) < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } } /* same_user mapping already exists: idempotent no-op */ if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_OK; } Conversation_Store_Result Conversation_Store_Transfer_Guest_To_User_Atomic( Conversation_Store *p_store, const char *guest_id, const char *user_id) { if (!p_store || !guest_id || !user_id || guest_id[0] == '\0' || user_id[0] == '\0') return CONVERSATION_STORE_ERROR; pthread_mutex_lock(&p_store->mutex); if (Deita_Query_Execute_Update( p_store->p_connection, "BEGIN IMMEDIATE") < 0) { pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } /* Check for an existing mapping for this guest_id. */ Dowa_Arena *p_arena = Dowa_Arena_Create(512); if (!p_arena) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } const char *check_params[] = {guest_id}; Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( p_store->p_connection, "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?", 1, check_params, p_arena); boolean mapping_exists = p_result && Deita_Result_Set_Next(p_result); const char *existing_user = mapping_exists ? Deita_Result_Set_Get_Text(p_result, 0) : NULL; boolean same_user = mapping_exists && existing_user && strcmp(existing_user, user_id) == 0; boolean conflict = mapping_exists && !same_user; if (p_result) Deita_Result_Set_Free(p_result); Dowa_Arena_Free(p_arena); if (conflict) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_CONFLICT; } if (!same_user) { const char *map_params[] = {guest_id, user_id}; if (Deita_Query_Execute_Update_Prepared( p_store->p_connection, "INSERT OR IGNORE INTO conversation_guest_transfers " "(guest_id, user_id) VALUES (?, ?)", 2, map_params) < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } const char *transfer_params[] = {user_id, guest_id}; if (Deita_Query_Execute_Update_Prepared( p_store->p_connection, "UPDATE conversations SET owner_kind = 'user', owner_id = ?, " "updated_at = strftime('%s','now') " "WHERE owner_kind = 'guest' AND owner_id = ?", 2, transfer_params) < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } } /* * Quota cleanup — auth tables reside in the same SQLite file so the write * lock already held by this transaction covers them too. Decrement * output_tokens_reserved and delete reservation rows for this guest. */ const char *quota_upd_params[] = {guest_id, guest_id}; if (Deita_Query_Execute_Update_Prepared( p_store->p_connection, "UPDATE guest_usage" " SET output_tokens_reserved = MAX(0, output_tokens_reserved - (" " SELECT COALESCE(SUM(r.output_tokens_reserved), 0)" " FROM guest_usage_reservations r" " WHERE r.guest_id = ? AND r.window_start = guest_usage.window_start" " ))" " WHERE guest_id = ?", 2, quota_upd_params) < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } const char *quota_del_params[] = {guest_id}; if (Deita_Query_Execute_Update_Prepared( p_store->p_connection, "DELETE FROM guest_usage_reservations WHERE guest_id = ?", 1, quota_del_params) < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0) { Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_ERROR; } pthread_mutex_unlock(&p_store->mutex); return CONVERSATION_STORE_OK; }