Mercurial
diff mrjunejune/conversation_store.c @ 264:04fee26ecce0
add authenticated JRPG conversation platform
Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 07:34:12 -0700 |
| parents | 1f9877b637e9 |
| children |
line wrap: on
line diff
--- a/mrjunejune/conversation_store.c Thu Aug 06 11:31:30 2026 -0700 +++ b/mrjunejune/conversation_store.c Fri Aug 07 07:34:12 2026 -0700 @@ -89,6 +89,173 @@ 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) @@ -150,12 +317,31 @@ "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);"; + "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 " @@ -170,6 +356,8 @@ return p_store; } + + void Conversation_Store_Destroy(Conversation_Store *p_store) { if (!p_store) @@ -494,3 +682,757 @@ 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; +}