Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 263:ee04e4e69fed | 264:04fee26ecce0 |
|---|---|
| 85 Conversation_Store *p_store, | 85 Conversation_Store *p_store, |
| 86 Conversation_Store_Result result) | 86 Conversation_Store_Result result) |
| 87 { | 87 { |
| 88 Deita_Query_Execute_Update(p_store->p_connection, "ROLLBACK"); | 88 Deita_Query_Execute_Update(p_store->p_connection, "ROLLBACK"); |
| 89 return result; | 89 return result; |
| 90 } | |
| 91 | |
| 92 /* ------------------------------------------------------------------ */ | |
| 93 /* Schema migration helpers (called from Conversation_Store_Create) */ | |
| 94 /* ------------------------------------------------------------------ */ | |
| 95 | |
| 96 static const char *Conversation_Store_Owner_Kind_String( | |
| 97 Conversation_Owner_Kind kind) | |
| 98 { | |
| 99 switch (kind) | |
| 100 { | |
| 101 case CONVERSATION_OWNER_KIND_USER: return "user"; | |
| 102 case CONVERSATION_OWNER_KIND_GUEST: return "guest"; | |
| 103 case CONVERSATION_OWNER_KIND_LEGACY: return "legacy"; | |
| 104 default: return "legacy"; | |
| 105 } | |
| 106 } | |
| 107 | |
| 108 /* Returns TRUE if column exists in table (mutex must NOT be held). */ | |
| 109 static boolean Conversation_Store_Column_Exists( | |
| 110 Conversation_Store *p_store, | |
| 111 const char *table_name, | |
| 112 const char *column_name) | |
| 113 { | |
| 114 char sql[256]; | |
| 115 snprintf(sql, sizeof(sql), | |
| 116 "SELECT 1 FROM pragma_table_info('%s') WHERE name = '%s'", | |
| 117 table_name, column_name); | |
| 118 Dowa_Arena *p_arena = Dowa_Arena_Create(512); | |
| 119 if (!p_arena) | |
| 120 return FALSE; | |
| 121 Deita_Result_Set *p_result = | |
| 122 Deita_Query_Execute_Prepared(p_store->p_connection, sql, 0, NULL, p_arena); | |
| 123 boolean exists = p_result && Deita_Result_Set_Next(p_result); | |
| 124 if (p_result) | |
| 125 Deita_Result_Set_Free(p_result); | |
| 126 Dowa_Arena_Free(p_arena); | |
| 127 return exists; | |
| 128 } | |
| 129 | |
| 130 /* | |
| 131 * Migration 1: add owner_kind / owner_id columns and listing index. | |
| 132 * Existing rows become owner_kind='legacy', owner_id=NULL. | |
| 133 * Safe to call on a database that was created by new code (idempotent). | |
| 134 */ | |
| 135 static boolean Conversation_Store_Apply_Migration_1( | |
| 136 Conversation_Store *p_store) | |
| 137 { | |
| 138 /* Check migrations ledger */ | |
| 139 Dowa_Arena *p_arena = Dowa_Arena_Create(512); | |
| 140 if (!p_arena) | |
| 141 return FALSE; | |
| 142 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 143 p_store->p_connection, | |
| 144 "SELECT 1 FROM conversation_schema_migrations WHERE version = 1", | |
| 145 0, NULL, p_arena); | |
| 146 boolean already_done = p_result && Deita_Result_Set_Next(p_result); | |
| 147 if (p_result) | |
| 148 Deita_Result_Set_Free(p_result); | |
| 149 Dowa_Arena_Free(p_arena); | |
| 150 if (already_done) | |
| 151 return TRUE; | |
| 152 | |
| 153 if (!Conversation_Store_Column_Exists(p_store, "conversations", "owner_kind")) | |
| 154 { | |
| 155 if (Deita_Query_Execute_Update( | |
| 156 p_store->p_connection, | |
| 157 "ALTER TABLE conversations ADD COLUMN owner_kind TEXT " | |
| 158 "NOT NULL DEFAULT 'legacy'") < 0) | |
| 159 return FALSE; | |
| 160 } | |
| 161 if (!Conversation_Store_Column_Exists(p_store, "conversations", "owner_id")) | |
| 162 { | |
| 163 if (Deita_Query_Execute_Update( | |
| 164 p_store->p_connection, | |
| 165 "ALTER TABLE conversations ADD COLUMN owner_id TEXT") < 0) | |
| 166 return FALSE; | |
| 167 } | |
| 168 if (Deita_Query_Execute_Update( | |
| 169 p_store->p_connection, | |
| 170 "CREATE INDEX IF NOT EXISTS idx_conversations_owner_listing " | |
| 171 "ON conversations(owner_kind, owner_id, updated_at DESC, id)") < 0) | |
| 172 return FALSE; | |
| 173 if (Deita_Query_Execute_Update( | |
| 174 p_store->p_connection, | |
| 175 "INSERT OR IGNORE INTO conversation_schema_migrations (version) " | |
| 176 "VALUES (1)") < 0) | |
| 177 return FALSE; | |
| 178 return TRUE; | |
| 179 } | |
| 180 | |
| 181 /* | |
| 182 * Migration 2: create the guest-to-user transfer mapping table. | |
| 183 * Records a permanent mapping from guest_id → user_id set at transfer time. | |
| 184 * Used by Create_Owned to redirect stale guest creates to the mapped user. | |
| 185 */ | |
| 186 static boolean Conversation_Store_Apply_Migration_2( | |
| 187 Conversation_Store *p_store) | |
| 188 { | |
| 189 Dowa_Arena *p_arena = Dowa_Arena_Create(512); | |
| 190 if (!p_arena) | |
| 191 return FALSE; | |
| 192 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 193 p_store->p_connection, | |
| 194 "SELECT 1 FROM conversation_schema_migrations WHERE version = 2", | |
| 195 0, NULL, p_arena); | |
| 196 boolean already_done = p_result && Deita_Result_Set_Next(p_result); | |
| 197 if (p_result) | |
| 198 Deita_Result_Set_Free(p_result); | |
| 199 Dowa_Arena_Free(p_arena); | |
| 200 if (already_done) | |
| 201 return TRUE; | |
| 202 | |
| 203 if (Deita_Query_Execute_Update( | |
| 204 p_store->p_connection, | |
| 205 "CREATE TABLE IF NOT EXISTS conversation_guest_transfers (" | |
| 206 "guest_id TEXT PRIMARY KEY," | |
| 207 "user_id TEXT NOT NULL," | |
| 208 "transferred_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))" | |
| 209 ")") < 0) | |
| 210 return FALSE; | |
| 211 if (Deita_Query_Execute_Update( | |
| 212 p_store->p_connection, | |
| 213 "INSERT OR IGNORE INTO conversation_schema_migrations (version) " | |
| 214 "VALUES (2)") < 0) | |
| 215 return FALSE; | |
| 216 return TRUE; | |
| 217 } | |
| 218 | |
| 219 /* | |
| 220 * Migration 3: recreate the owner listing index with id DESC so that | |
| 221 * keyset pagination is stable when updated_at values collide. | |
| 222 * Drops and recreates the index atomically from the migration ledger's | |
| 223 * perspective; safe to run on any database that has migration 1 applied. | |
| 224 */ | |
| 225 static boolean Conversation_Store_Apply_Migration_3( | |
| 226 Conversation_Store *p_store) | |
| 227 { | |
| 228 Dowa_Arena *p_arena = Dowa_Arena_Create(512); | |
| 229 if (!p_arena) | |
| 230 return FALSE; | |
| 231 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 232 p_store->p_connection, | |
| 233 "SELECT 1 FROM conversation_schema_migrations WHERE version = 3", | |
| 234 0, NULL, p_arena); | |
| 235 boolean already_done = p_result && Deita_Result_Set_Next(p_result); | |
| 236 if (p_result) | |
| 237 Deita_Result_Set_Free(p_result); | |
| 238 Dowa_Arena_Free(p_arena); | |
| 239 if (already_done) | |
| 240 return TRUE; | |
| 241 | |
| 242 if (Deita_Query_Execute_Update( | |
| 243 p_store->p_connection, | |
| 244 "DROP INDEX IF EXISTS idx_conversations_owner_listing") < 0) | |
| 245 return FALSE; | |
| 246 if (Deita_Query_Execute_Update( | |
| 247 p_store->p_connection, | |
| 248 "CREATE INDEX IF NOT EXISTS idx_conversations_owner_listing " | |
| 249 "ON conversations(owner_kind, owner_id, updated_at DESC, id DESC)") < 0) | |
| 250 return FALSE; | |
| 251 if (Deita_Query_Execute_Update( | |
| 252 p_store->p_connection, | |
| 253 "INSERT OR IGNORE INTO conversation_schema_migrations (version) " | |
| 254 "VALUES (3)") < 0) | |
| 255 return FALSE; | |
| 256 return TRUE; | |
| 90 } | 257 } |
| 91 | 258 |
| 92 Conversation_Store *Conversation_Store_Create(const char *database_path) | 259 Conversation_Store *Conversation_Store_Create(const char *database_path) |
| 93 { | 260 { |
| 94 if (!database_path) | 261 if (!database_path) |
| 148 "ON conversation_turns(conversation_id) " | 315 "ON conversation_turns(conversation_id) " |
| 149 "WHERE role = 'assistant' AND status = 'active';" | 316 "WHERE role = 'assistant' AND status = 'active';" |
| 150 "CREATE INDEX IF NOT EXISTS idx_conversations_updated " | 317 "CREATE INDEX IF NOT EXISTS idx_conversations_updated " |
| 151 "ON conversations(updated_at DESC);" | 318 "ON conversations(updated_at DESC);" |
| 152 "CREATE INDEX IF NOT EXISTS idx_turns_conversation_sequence " | 319 "CREATE INDEX IF NOT EXISTS idx_turns_conversation_sequence " |
| 153 "ON conversation_turns(conversation_id, sequence);"; | 320 "ON conversation_turns(conversation_id, sequence);" |
| 321 "CREATE TABLE IF NOT EXISTS conversation_schema_migrations (" | |
| 322 "version INTEGER PRIMARY KEY," | |
| 323 "applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))" | |
| 324 ");"; | |
| 154 if (Deita_Query_Execute_Update(p_store->p_connection, schema) < 0) | 325 if (Deita_Query_Execute_Update(p_store->p_connection, schema) < 0) |
| 326 { | |
| 327 Conversation_Store_Destroy(p_store); | |
| 328 return NULL; | |
| 329 } | |
| 330 if (!Conversation_Store_Apply_Migration_1(p_store)) | |
| 331 { | |
| 332 Conversation_Store_Destroy(p_store); | |
| 333 return NULL; | |
| 334 } | |
| 335 if (!Conversation_Store_Apply_Migration_2(p_store)) | |
| 336 { | |
| 337 Conversation_Store_Destroy(p_store); | |
| 338 return NULL; | |
| 339 } | |
| 340 if (!Conversation_Store_Apply_Migration_3(p_store)) | |
| 155 { | 341 { |
| 156 Conversation_Store_Destroy(p_store); | 342 Conversation_Store_Destroy(p_store); |
| 157 return NULL; | 343 return NULL; |
| 158 } | 344 } |
| 159 if (Deita_Query_Execute_Update( | 345 if (Deita_Query_Execute_Update( |
| 167 Conversation_Store_Destroy(p_store); | 353 Conversation_Store_Destroy(p_store); |
| 168 return NULL; | 354 return NULL; |
| 169 } | 355 } |
| 170 return p_store; | 356 return p_store; |
| 171 } | 357 } |
| 358 | |
| 359 | |
| 172 | 360 |
| 173 void Conversation_Store_Destroy(Conversation_Store *p_store) | 361 void Conversation_Store_Destroy(Conversation_Store *p_store) |
| 174 { | 362 { |
| 175 if (!p_store) | 363 if (!p_store) |
| 176 return; | 364 return; |
| 492 pthread_mutex_unlock(&p_store->mutex); | 680 pthread_mutex_unlock(&p_store->mutex); |
| 493 if (result < 0) | 681 if (result < 0) |
| 494 return CONVERSATION_STORE_ERROR; | 682 return CONVERSATION_STORE_ERROR; |
| 495 return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK; | 683 return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK; |
| 496 } | 684 } |
| 685 | |
| 686 /* ------------------------------------------------------------------ */ | |
| 687 /* Owner-aware APIs */ | |
| 688 /* ------------------------------------------------------------------ */ | |
| 689 | |
| 690 Conversation_Store_Result Conversation_Store_Create_Owned( | |
| 691 Conversation_Store *p_store, | |
| 692 const char *title, | |
| 693 const Conversation_Owner *p_owner, | |
| 694 char output_id[37]) | |
| 695 { | |
| 696 if (!p_store || !p_owner || !output_id) | |
| 697 return CONVERSATION_STORE_ERROR; | |
| 698 if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || | |
| 699 p_owner->id[0] == '\0') | |
| 700 return CONVERSATION_STORE_ERROR; | |
| 701 if (!Conversation_Store_Generate_UUID(output_id)) | |
| 702 return CONVERSATION_STORE_ERROR; | |
| 703 | |
| 704 /* Resolved owner fields — may be overridden by transfer mapping below */ | |
| 705 const char *resolved_kind = Conversation_Store_Owner_Kind_String(p_owner->kind); | |
| 706 char resolved_id[37]; | |
| 707 strncpy(resolved_id, p_owner->id, 36); | |
| 708 resolved_id[36] = '\0'; | |
| 709 | |
| 710 pthread_mutex_lock(&p_store->mutex); | |
| 711 if (Deita_Query_Execute_Update( | |
| 712 p_store->p_connection, "BEGIN IMMEDIATE") < 0) | |
| 713 { | |
| 714 pthread_mutex_unlock(&p_store->mutex); | |
| 715 return CONVERSATION_STORE_ERROR; | |
| 716 } | |
| 717 | |
| 718 /* For guest owners: check transfer mapping within the same transaction. | |
| 719 * If the guest was transferred to a user, assign to that user instead. */ | |
| 720 if (p_owner->kind == CONVERSATION_OWNER_KIND_GUEST) | |
| 721 { | |
| 722 Dowa_Arena *p_arena = Dowa_Arena_Create(512); | |
| 723 if (!p_arena) | |
| 724 { | |
| 725 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 726 pthread_mutex_unlock(&p_store->mutex); | |
| 727 return CONVERSATION_STORE_ERROR; | |
| 728 } | |
| 729 const char *check_params[] = {p_owner->id}; | |
| 730 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 731 p_store->p_connection, | |
| 732 "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?", | |
| 733 1, check_params, p_arena); | |
| 734 if (p_result && Deita_Result_Set_Next(p_result)) | |
| 735 { | |
| 736 const char *mapped_user = Deita_Result_Set_Get_Text(p_result, 0); | |
| 737 if (mapped_user && mapped_user[0] != '\0') | |
| 738 { | |
| 739 strncpy(resolved_id, mapped_user, 36); | |
| 740 resolved_id[36] = '\0'; | |
| 741 resolved_kind = "user"; | |
| 742 } | |
| 743 } | |
| 744 if (p_result) | |
| 745 Deita_Result_Set_Free(p_result); | |
| 746 Dowa_Arena_Free(p_arena); | |
| 747 } | |
| 748 | |
| 749 const char *parameters[] = { | |
| 750 output_id, | |
| 751 output_id, | |
| 752 title ? title : "", | |
| 753 resolved_kind, | |
| 754 resolved_id, | |
| 755 }; | |
| 756 int32 result = Deita_Query_Execute_Update_Prepared( | |
| 757 p_store->p_connection, | |
| 758 "INSERT INTO conversations " | |
| 759 "(id, copilot_session_id, title, owner_kind, owner_id) " | |
| 760 "VALUES (?, ?, ?, ?, ?)", | |
| 761 5, | |
| 762 parameters); | |
| 763 if (result < 0 || | |
| 764 Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0) | |
| 765 { | |
| 766 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 767 pthread_mutex_unlock(&p_store->mutex); | |
| 768 return CONVERSATION_STORE_ERROR; | |
| 769 } | |
| 770 pthread_mutex_unlock(&p_store->mutex); | |
| 771 return result < 0 ? CONVERSATION_STORE_ERROR : CONVERSATION_STORE_OK; | |
| 772 } | |
| 773 | |
| 774 Conversation_Store_Result Conversation_Store_Get_Owned( | |
| 775 Conversation_Store *p_store, | |
| 776 const char *conversation_id, | |
| 777 const Conversation_Owner *p_owner, | |
| 778 Conversation_Record *p_record, | |
| 779 Dowa_Arena *p_arena) | |
| 780 { | |
| 781 if (!p_store || !conversation_id || !p_owner || !p_record || !p_arena) | |
| 782 return CONVERSATION_STORE_ERROR; | |
| 783 if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || | |
| 784 p_owner->id[0] == '\0') | |
| 785 return CONVERSATION_STORE_ERROR; | |
| 786 memset(p_record, 0, sizeof(*p_record)); | |
| 787 | |
| 788 const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind); | |
| 789 const char *parameters[] = {conversation_id, kind_str, p_owner->id}; | |
| 790 pthread_mutex_lock(&p_store->mutex); | |
| 791 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 792 p_store->p_connection, | |
| 793 "SELECT id, copilot_session_id, title, status, created_at, updated_at " | |
| 794 "FROM conversations " | |
| 795 "WHERE id = ? AND status != 'deleted' " | |
| 796 "AND owner_kind = ? AND owner_id = ?", | |
| 797 3, | |
| 798 parameters, | |
| 799 p_arena); | |
| 800 if (!p_result || !Deita_Result_Set_Next(p_result)) | |
| 801 { | |
| 802 if (p_result) | |
| 803 Deita_Result_Set_Free(p_result); | |
| 804 pthread_mutex_unlock(&p_store->mutex); | |
| 805 return CONVERSATION_STORE_NOT_FOUND; | |
| 806 } | |
| 807 p_record->id = Conversation_Store_Copy_Text( | |
| 808 Deita_Result_Set_Get_Text(p_result, 0), p_arena); | |
| 809 p_record->copilot_session_id = Conversation_Store_Copy_Text( | |
| 810 Deita_Result_Set_Get_Text(p_result, 1), p_arena); | |
| 811 p_record->title = Conversation_Store_Copy_Text( | |
| 812 Deita_Result_Set_Get_Text(p_result, 2), p_arena); | |
| 813 p_record->status = Conversation_Store_Copy_Text( | |
| 814 Deita_Result_Set_Get_Text(p_result, 3), p_arena); | |
| 815 p_record->created_at = Deita_Result_Set_Get_Integer(p_result, 4); | |
| 816 p_record->updated_at = Deita_Result_Set_Get_Integer(p_result, 5); | |
| 817 Deita_Result_Set_Free(p_result); | |
| 818 | |
| 819 const char *turn_params[] = {conversation_id}; | |
| 820 p_result = Deita_Query_Execute_Prepared( | |
| 821 p_store->p_connection, | |
| 822 "SELECT id, sequence, role, content, status, request_id, " | |
| 823 "error_message, input_tokens, output_tokens, created_at, completed_at " | |
| 824 "FROM (SELECT id, sequence, role, content, status, request_id, " | |
| 825 "error_message, input_tokens, output_tokens, created_at, completed_at " | |
| 826 "FROM conversation_turns WHERE conversation_id = ? " | |
| 827 "ORDER BY sequence DESC LIMIT 20) ORDER BY sequence", | |
| 828 1, | |
| 829 turn_params, | |
| 830 p_arena); | |
| 831 if (!p_result) | |
| 832 { | |
| 833 pthread_mutex_unlock(&p_store->mutex); | |
| 834 return CONVERSATION_STORE_ERROR; | |
| 835 } | |
| 836 while (Deita_Result_Set_Next(p_result)) | |
| 837 { | |
| 838 Conversation_Turn turn = {0}; | |
| 839 turn.id = Deita_Result_Set_Get_Integer(p_result, 0); | |
| 840 turn.sequence = Deita_Result_Set_Get_Integer(p_result, 1); | |
| 841 turn.role = Conversation_Store_Copy_Text( | |
| 842 Deita_Result_Set_Get_Text(p_result, 2), p_arena); | |
| 843 turn.content = Conversation_Store_Copy_Text( | |
| 844 Deita_Result_Set_Get_Text(p_result, 3), p_arena); | |
| 845 turn.status = Conversation_Store_Copy_Text( | |
| 846 Deita_Result_Set_Get_Text(p_result, 4), p_arena); | |
| 847 turn.request_id = Conversation_Store_Copy_Text( | |
| 848 Deita_Result_Set_Get_Text(p_result, 5), p_arena); | |
| 849 turn.error_message = Conversation_Store_Copy_Text( | |
| 850 Deita_Result_Set_Get_Text(p_result, 6), p_arena); | |
| 851 turn.input_tokens = Deita_Result_Set_Get_Integer(p_result, 7); | |
| 852 turn.output_tokens = Deita_Result_Set_Get_Integer(p_result, 8); | |
| 853 turn.created_at = Deita_Result_Set_Get_Integer(p_result, 9); | |
| 854 turn.completed_at = Deita_Result_Set_Get_Integer(p_result, 10); | |
| 855 Dowa_Array_Push_Arena(p_record->turns, turn, p_arena); | |
| 856 } | |
| 857 boolean turns_error = Deita_Result_Set_Has_Error(p_result); | |
| 858 Deita_Result_Set_Free(p_result); | |
| 859 pthread_mutex_unlock(&p_store->mutex); | |
| 860 return turns_error ? CONVERSATION_STORE_ERROR : CONVERSATION_STORE_OK; | |
| 861 } | |
| 862 | |
| 863 Conversation_Store_Result Conversation_Store_Update_Title_Owned( | |
| 864 Conversation_Store *p_store, | |
| 865 const char *conversation_id, | |
| 866 const Conversation_Owner *p_owner, | |
| 867 const char *title) | |
| 868 { | |
| 869 if (!p_store || !conversation_id || !p_owner || !title) | |
| 870 return CONVERSATION_STORE_ERROR; | |
| 871 if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || | |
| 872 p_owner->id[0] == '\0') | |
| 873 return CONVERSATION_STORE_ERROR; | |
| 874 | |
| 875 const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind); | |
| 876 const char *parameters[] = {title, conversation_id, kind_str, p_owner->id}; | |
| 877 pthread_mutex_lock(&p_store->mutex); | |
| 878 int32 result = Deita_Query_Execute_Update_Prepared( | |
| 879 p_store->p_connection, | |
| 880 "UPDATE conversations SET title = ?, " | |
| 881 "updated_at = strftime('%s','now') " | |
| 882 "WHERE id = ? AND status != 'deleted' " | |
| 883 "AND owner_kind = ? AND owner_id = ?", | |
| 884 4, | |
| 885 parameters); | |
| 886 pthread_mutex_unlock(&p_store->mutex); | |
| 887 if (result < 0) | |
| 888 return CONVERSATION_STORE_ERROR; | |
| 889 return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK; | |
| 890 } | |
| 891 | |
| 892 Conversation_Store_Result Conversation_Store_Delete_Owned( | |
| 893 Conversation_Store *p_store, | |
| 894 const char *conversation_id, | |
| 895 const Conversation_Owner *p_owner) | |
| 896 { | |
| 897 if (!p_store || !conversation_id || !p_owner) | |
| 898 return CONVERSATION_STORE_ERROR; | |
| 899 if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || | |
| 900 p_owner->id[0] == '\0') | |
| 901 return CONVERSATION_STORE_ERROR; | |
| 902 | |
| 903 const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind); | |
| 904 const char *parameters[] = {conversation_id, kind_str, p_owner->id}; | |
| 905 pthread_mutex_lock(&p_store->mutex); | |
| 906 int32 result = Deita_Query_Execute_Update_Prepared( | |
| 907 p_store->p_connection, | |
| 908 "DELETE FROM conversations WHERE id = ? " | |
| 909 "AND owner_kind = ? AND owner_id = ?", | |
| 910 3, | |
| 911 parameters); | |
| 912 pthread_mutex_unlock(&p_store->mutex); | |
| 913 if (result < 0) | |
| 914 return CONVERSATION_STORE_ERROR; | |
| 915 return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK; | |
| 916 } | |
| 917 | |
| 918 /* Checks conversation ownership without loading turns (called under mutex). */ | |
| 919 static boolean Conversation_Store_Owns_Locked( | |
| 920 Conversation_Store *p_store, | |
| 921 const char *conversation_id, | |
| 922 const Conversation_Owner *p_owner) | |
| 923 { | |
| 924 const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind); | |
| 925 const char *parameters[] = {conversation_id, kind_str, p_owner->id}; | |
| 926 Dowa_Arena *p_arena = Dowa_Arena_Create(1024); | |
| 927 if (!p_arena) | |
| 928 return FALSE; | |
| 929 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 930 p_store->p_connection, | |
| 931 "SELECT 1 FROM conversations " | |
| 932 "WHERE id = ? AND status != 'deleted' " | |
| 933 "AND owner_kind = ? AND owner_id = ?", | |
| 934 3, | |
| 935 parameters, | |
| 936 p_arena); | |
| 937 boolean owns = p_result && Deita_Result_Set_Next(p_result); | |
| 938 if (p_result) | |
| 939 Deita_Result_Set_Free(p_result); | |
| 940 Dowa_Arena_Free(p_arena); | |
| 941 return owns; | |
| 942 } | |
| 943 | |
| 944 Conversation_Store_Result Conversation_Store_Begin_Turn_Owned( | |
| 945 Conversation_Store *p_store, | |
| 946 const char *conversation_id, | |
| 947 const Conversation_Owner *p_owner, | |
| 948 const char *request_id, | |
| 949 const char *prompt) | |
| 950 { | |
| 951 if (!p_store || !conversation_id || !p_owner || !request_id || !prompt) | |
| 952 return CONVERSATION_STORE_ERROR; | |
| 953 if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || | |
| 954 p_owner->id[0] == '\0') | |
| 955 return CONVERSATION_STORE_ERROR; | |
| 956 | |
| 957 pthread_mutex_lock(&p_store->mutex); | |
| 958 if (Deita_Query_Execute_Update( | |
| 959 p_store->p_connection, "BEGIN IMMEDIATE") < 0) | |
| 960 { | |
| 961 pthread_mutex_unlock(&p_store->mutex); | |
| 962 return CONVERSATION_STORE_ERROR; | |
| 963 } | |
| 964 if (!Conversation_Store_Owns_Locked(p_store, conversation_id, p_owner)) | |
| 965 { | |
| 966 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_NOT_FOUND); | |
| 967 pthread_mutex_unlock(&p_store->mutex); | |
| 968 return CONVERSATION_STORE_NOT_FOUND; | |
| 969 } | |
| 970 | |
| 971 Dowa_Arena *p_arena = Dowa_Arena_Create(2048); | |
| 972 const char *seq_params[] = {conversation_id}; | |
| 973 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 974 p_store->p_connection, | |
| 975 "SELECT COALESCE(MAX(sequence), 0), " | |
| 976 "SUM(CASE WHEN role = 'assistant' AND status = 'active' " | |
| 977 "THEN 1 ELSE 0 END) " | |
| 978 "FROM conversation_turns WHERE conversation_id = ?", | |
| 979 1, | |
| 980 seq_params, | |
| 981 p_arena); | |
| 982 if (!p_result || !Deita_Result_Set_Next(p_result)) | |
| 983 { | |
| 984 if (p_result) | |
| 985 Deita_Result_Set_Free(p_result); | |
| 986 Dowa_Arena_Free(p_arena); | |
| 987 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 988 pthread_mutex_unlock(&p_store->mutex); | |
| 989 return CONVERSATION_STORE_ERROR; | |
| 990 } | |
| 991 int64 next_sequence = Deita_Result_Set_Get_Integer(p_result, 0) + 1; | |
| 992 int64 active_count = Deita_Result_Set_Get_Integer(p_result, 1); | |
| 993 Deita_Result_Set_Free(p_result); | |
| 994 Dowa_Arena_Free(p_arena); | |
| 995 if (active_count > 0) | |
| 996 { | |
| 997 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT); | |
| 998 pthread_mutex_unlock(&p_store->mutex); | |
| 999 return CONVERSATION_STORE_CONFLICT; | |
| 1000 } | |
| 1001 | |
| 1002 char user_sequence[32]; | |
| 1003 char assistant_sequence[32]; | |
| 1004 snprintf(user_sequence, sizeof(user_sequence), "%lld", | |
| 1005 (long long)next_sequence); | |
| 1006 snprintf(assistant_sequence, sizeof(assistant_sequence), "%lld", | |
| 1007 (long long)(next_sequence + 1)); | |
| 1008 const char *user_params[] = { | |
| 1009 conversation_id, user_sequence, prompt, request_id, | |
| 1010 }; | |
| 1011 if (Deita_Query_Execute_Update_Prepared( | |
| 1012 p_store->p_connection, | |
| 1013 "INSERT INTO conversation_turns " | |
| 1014 "(conversation_id, sequence, role, content, status, request_id, " | |
| 1015 "completed_at) VALUES (?, ?, 'user', ?, 'complete', ?, " | |
| 1016 "strftime('%s','now'))", | |
| 1017 4, | |
| 1018 user_params) < 0) | |
| 1019 { | |
| 1020 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1021 pthread_mutex_unlock(&p_store->mutex); | |
| 1022 return CONVERSATION_STORE_ERROR; | |
| 1023 } | |
| 1024 const char *asst_params[] = {conversation_id, assistant_sequence, request_id}; | |
| 1025 const char *conv_params[] = {conversation_id}; | |
| 1026 if (Deita_Query_Execute_Update_Prepared( | |
| 1027 p_store->p_connection, | |
| 1028 "INSERT INTO conversation_turns " | |
| 1029 "(conversation_id, sequence, role, status, request_id) " | |
| 1030 "VALUES (?, ?, 'assistant', 'active', ?)", | |
| 1031 3, | |
| 1032 asst_params) < 0 || | |
| 1033 Deita_Query_Execute_Update_Prepared( | |
| 1034 p_store->p_connection, | |
| 1035 "UPDATE conversations SET updated_at = strftime('%s','now') " | |
| 1036 "WHERE id = ?", | |
| 1037 1, | |
| 1038 conv_params) < 0 || | |
| 1039 Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0) | |
| 1040 { | |
| 1041 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1042 pthread_mutex_unlock(&p_store->mutex); | |
| 1043 return CONVERSATION_STORE_ERROR; | |
| 1044 } | |
| 1045 pthread_mutex_unlock(&p_store->mutex); | |
| 1046 return CONVERSATION_STORE_OK; | |
| 1047 } | |
| 1048 | |
| 1049 Conversation_Store_Result Conversation_Store_List( | |
| 1050 Conversation_Store *p_store, | |
| 1051 const Conversation_Owner *p_owner, | |
| 1052 int64 cursor_updated_at, | |
| 1053 const char *cursor_id, | |
| 1054 int32 limit, | |
| 1055 Conversation_Summary **pp_summaries, | |
| 1056 int32 *p_count, | |
| 1057 Dowa_Arena *p_arena) | |
| 1058 { | |
| 1059 if (!p_store || !p_owner || !pp_summaries || !p_count || !p_arena) | |
| 1060 return CONVERSATION_STORE_ERROR; | |
| 1061 *pp_summaries = NULL; | |
| 1062 *p_count = 0; | |
| 1063 | |
| 1064 /* Legacy conversations are never listed */ | |
| 1065 if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY || | |
| 1066 p_owner->id[0] == '\0') | |
| 1067 return CONVERSATION_STORE_OK; | |
| 1068 | |
| 1069 if (limit < 1) limit = 1; | |
| 1070 if (limit > 50) limit = 50; | |
| 1071 | |
| 1072 const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind); | |
| 1073 char limit_str[16]; | |
| 1074 snprintf(limit_str, sizeof(limit_str), "%d", limit); | |
| 1075 | |
| 1076 Deita_Result_Set *p_result; | |
| 1077 pthread_mutex_lock(&p_store->mutex); | |
| 1078 | |
| 1079 if (cursor_updated_at > 0 && cursor_id && cursor_id[0] != '\0') | |
| 1080 { | |
| 1081 char ts_str[32]; | |
| 1082 snprintf(ts_str, sizeof(ts_str), "%lld", (long long)cursor_updated_at); | |
| 1083 const char *params[] = { | |
| 1084 kind_str, p_owner->id, ts_str, ts_str, cursor_id, limit_str, | |
| 1085 }; | |
| 1086 p_result = Deita_Query_Execute_Prepared( | |
| 1087 p_store->p_connection, | |
| 1088 "SELECT id, title, status, created_at, updated_at, " | |
| 1089 "(SELECT COUNT(*) FROM conversation_turns " | |
| 1090 " WHERE conversation_id = conversations.id) AS turn_count, " | |
| 1091 "(SELECT SUBSTR(content, 1, 201) FROM conversation_turns " | |
| 1092 " WHERE conversation_id = conversations.id " | |
| 1093 " ORDER BY sequence DESC LIMIT 1) AS last_msg " | |
| 1094 "FROM conversations " | |
| 1095 "WHERE owner_kind = ? AND owner_id = ? AND status != 'deleted' " | |
| 1096 "AND (updated_at < ? OR (updated_at = ? AND id < ?)) " | |
| 1097 "ORDER BY updated_at DESC, id DESC LIMIT ?", | |
| 1098 6, params, p_arena); | |
| 1099 } | |
| 1100 else | |
| 1101 { | |
| 1102 const char *params[] = {kind_str, p_owner->id, limit_str}; | |
| 1103 p_result = Deita_Query_Execute_Prepared( | |
| 1104 p_store->p_connection, | |
| 1105 "SELECT id, title, status, created_at, updated_at, " | |
| 1106 "(SELECT COUNT(*) FROM conversation_turns " | |
| 1107 " WHERE conversation_id = conversations.id) AS turn_count, " | |
| 1108 "(SELECT SUBSTR(content, 1, 201) FROM conversation_turns " | |
| 1109 " WHERE conversation_id = conversations.id " | |
| 1110 " ORDER BY sequence DESC LIMIT 1) AS last_msg " | |
| 1111 "FROM conversations " | |
| 1112 "WHERE owner_kind = ? AND owner_id = ? AND status != 'deleted' " | |
| 1113 "ORDER BY updated_at DESC, id DESC LIMIT ?", | |
| 1114 3, params, p_arena); | |
| 1115 } | |
| 1116 | |
| 1117 if (!p_result) | |
| 1118 { | |
| 1119 pthread_mutex_unlock(&p_store->mutex); | |
| 1120 return CONVERSATION_STORE_ERROR; | |
| 1121 } | |
| 1122 | |
| 1123 Conversation_Summary *summaries = NULL; | |
| 1124 while (Deita_Result_Set_Next(p_result)) | |
| 1125 { | |
| 1126 Conversation_Summary s = {0}; | |
| 1127 s.id = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 0), p_arena); | |
| 1128 s.title = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 1), p_arena); | |
| 1129 s.status = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 2), p_arena); | |
| 1130 s.created_at = Deita_Result_Set_Get_Integer(p_result, 3); | |
| 1131 s.updated_at = Deita_Result_Set_Get_Integer(p_result, 4); | |
| 1132 s.turn_count = Deita_Result_Set_Get_Integer(p_result, 5); | |
| 1133 const char *last_msg_raw = Deita_Result_Set_Get_Text(p_result, 6); | |
| 1134 if (last_msg_raw && last_msg_raw[0] != '\0') | |
| 1135 { | |
| 1136 /* Truncate to 200 chars maximum */ | |
| 1137 size_t msg_len = strlen(last_msg_raw); | |
| 1138 if (msg_len > 200) msg_len = 200; | |
| 1139 char *preview = Dowa_Arena_Allocate(p_arena, msg_len + 1); | |
| 1140 if (preview) | |
| 1141 { | |
| 1142 memcpy(preview, last_msg_raw, msg_len); | |
| 1143 preview[msg_len] = '\0'; | |
| 1144 } | |
| 1145 s.last_message_preview = preview; | |
| 1146 } | |
| 1147 else | |
| 1148 { | |
| 1149 s.last_message_preview = Conversation_Store_Copy_Text("", p_arena); | |
| 1150 } | |
| 1151 Dowa_Array_Push_Arena(summaries, s, p_arena); | |
| 1152 } | |
| 1153 boolean has_error = Deita_Result_Set_Has_Error(p_result); | |
| 1154 Deita_Result_Set_Free(p_result); | |
| 1155 pthread_mutex_unlock(&p_store->mutex); | |
| 1156 | |
| 1157 if (has_error) | |
| 1158 return CONVERSATION_STORE_ERROR; | |
| 1159 | |
| 1160 *pp_summaries = summaries; | |
| 1161 *p_count = (int32)Dowa_Array_Length(summaries); | |
| 1162 return CONVERSATION_STORE_OK; | |
| 1163 } | |
| 1164 | |
| 1165 Conversation_Store_Result Conversation_Store_Claim_Legacy( | |
| 1166 Conversation_Store *p_store, | |
| 1167 const char *conversation_id, | |
| 1168 const char *user_id) | |
| 1169 { | |
| 1170 if (!p_store || !conversation_id || !user_id || user_id[0] == '\0') | |
| 1171 return CONVERSATION_STORE_ERROR; | |
| 1172 | |
| 1173 pthread_mutex_lock(&p_store->mutex); | |
| 1174 if (Deita_Query_Execute_Update( | |
| 1175 p_store->p_connection, "BEGIN IMMEDIATE") < 0) | |
| 1176 { | |
| 1177 pthread_mutex_unlock(&p_store->mutex); | |
| 1178 return CONVERSATION_STORE_ERROR; | |
| 1179 } | |
| 1180 | |
| 1181 Dowa_Arena *p_arena = Dowa_Arena_Create(512); | |
| 1182 if (!p_arena) | |
| 1183 { | |
| 1184 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1185 pthread_mutex_unlock(&p_store->mutex); | |
| 1186 return CONVERSATION_STORE_ERROR; | |
| 1187 } | |
| 1188 const char *check_params[] = {conversation_id}; | |
| 1189 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 1190 p_store->p_connection, | |
| 1191 "SELECT owner_kind FROM conversations " | |
| 1192 "WHERE id = ? AND status != 'deleted'", | |
| 1193 1, check_params, p_arena); | |
| 1194 boolean found = p_result && Deita_Result_Set_Next(p_result); | |
| 1195 const char *existing_kind = found | |
| 1196 ? Deita_Result_Set_Get_Text(p_result, 0) | |
| 1197 : NULL; | |
| 1198 boolean is_claimable = | |
| 1199 existing_kind && strcmp(existing_kind, "legacy") == 0; | |
| 1200 if (p_result) Deita_Result_Set_Free(p_result); | |
| 1201 Dowa_Arena_Free(p_arena); | |
| 1202 | |
| 1203 if (!found) | |
| 1204 { | |
| 1205 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_NOT_FOUND); | |
| 1206 pthread_mutex_unlock(&p_store->mutex); | |
| 1207 return CONVERSATION_STORE_NOT_FOUND; | |
| 1208 } | |
| 1209 if (!is_claimable) | |
| 1210 { | |
| 1211 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT); | |
| 1212 pthread_mutex_unlock(&p_store->mutex); | |
| 1213 return CONVERSATION_STORE_CONFLICT; | |
| 1214 } | |
| 1215 | |
| 1216 const char *update_params[] = {user_id, conversation_id}; | |
| 1217 int32 updated = Deita_Query_Execute_Update_Prepared( | |
| 1218 p_store->p_connection, | |
| 1219 "UPDATE conversations SET owner_kind = 'user', owner_id = ?, " | |
| 1220 "updated_at = strftime('%s','now') " | |
| 1221 "WHERE id = ? AND owner_kind = 'legacy'", | |
| 1222 2, update_params); | |
| 1223 if (updated < 0 || | |
| 1224 Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0) | |
| 1225 { | |
| 1226 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1227 pthread_mutex_unlock(&p_store->mutex); | |
| 1228 return CONVERSATION_STORE_ERROR; | |
| 1229 } | |
| 1230 pthread_mutex_unlock(&p_store->mutex); | |
| 1231 return updated > 0 ? CONVERSATION_STORE_OK : CONVERSATION_STORE_NOT_FOUND; | |
| 1232 } | |
| 1233 | |
| 1234 Conversation_Store_Result Conversation_Store_Transfer_Guest_To_User( | |
| 1235 Conversation_Store *p_store, | |
| 1236 const char *guest_id, | |
| 1237 const char *user_id) | |
| 1238 { | |
| 1239 if (!p_store || !guest_id || !user_id || | |
| 1240 guest_id[0] == '\0' || user_id[0] == '\0') | |
| 1241 return CONVERSATION_STORE_ERROR; | |
| 1242 | |
| 1243 pthread_mutex_lock(&p_store->mutex); | |
| 1244 if (Deita_Query_Execute_Update( | |
| 1245 p_store->p_connection, "BEGIN IMMEDIATE") < 0) | |
| 1246 { | |
| 1247 pthread_mutex_unlock(&p_store->mutex); | |
| 1248 return CONVERSATION_STORE_ERROR; | |
| 1249 } | |
| 1250 | |
| 1251 /* Check for an existing mapping for this guest_id */ | |
| 1252 Dowa_Arena *p_arena = Dowa_Arena_Create(512); | |
| 1253 if (!p_arena) | |
| 1254 { | |
| 1255 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1256 pthread_mutex_unlock(&p_store->mutex); | |
| 1257 return CONVERSATION_STORE_ERROR; | |
| 1258 } | |
| 1259 const char *check_params[] = {guest_id}; | |
| 1260 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 1261 p_store->p_connection, | |
| 1262 "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?", | |
| 1263 1, check_params, p_arena); | |
| 1264 boolean mapping_exists = p_result && Deita_Result_Set_Next(p_result); | |
| 1265 const char *existing_user = mapping_exists | |
| 1266 ? Deita_Result_Set_Get_Text(p_result, 0) : NULL; | |
| 1267 boolean same_user = mapping_exists && existing_user && | |
| 1268 strcmp(existing_user, user_id) == 0; | |
| 1269 boolean conflict = mapping_exists && !same_user; | |
| 1270 if (p_result) | |
| 1271 Deita_Result_Set_Free(p_result); | |
| 1272 Dowa_Arena_Free(p_arena); | |
| 1273 | |
| 1274 if (conflict) | |
| 1275 { | |
| 1276 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT); | |
| 1277 pthread_mutex_unlock(&p_store->mutex); | |
| 1278 return CONVERSATION_STORE_CONFLICT; | |
| 1279 } | |
| 1280 | |
| 1281 if (!mapping_exists) | |
| 1282 { | |
| 1283 /* Record the mapping so future Create_Owned for this guest uses user */ | |
| 1284 const char *map_params[] = {guest_id, user_id}; | |
| 1285 if (Deita_Query_Execute_Update_Prepared( | |
| 1286 p_store->p_connection, | |
| 1287 "INSERT OR IGNORE INTO conversation_guest_transfers " | |
| 1288 "(guest_id, user_id) VALUES (?, ?)", | |
| 1289 2, map_params) < 0) | |
| 1290 { | |
| 1291 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1292 pthread_mutex_unlock(&p_store->mutex); | |
| 1293 return CONVERSATION_STORE_ERROR; | |
| 1294 } | |
| 1295 | |
| 1296 /* Transfer existing conversations from guest to user */ | |
| 1297 const char *transfer_params[] = {user_id, guest_id}; | |
| 1298 if (Deita_Query_Execute_Update_Prepared( | |
| 1299 p_store->p_connection, | |
| 1300 "UPDATE conversations SET owner_kind = 'user', owner_id = ?, " | |
| 1301 "updated_at = strftime('%s','now') " | |
| 1302 "WHERE owner_kind = 'guest' AND owner_id = ?", | |
| 1303 2, transfer_params) < 0) | |
| 1304 { | |
| 1305 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1306 pthread_mutex_unlock(&p_store->mutex); | |
| 1307 return CONVERSATION_STORE_ERROR; | |
| 1308 } | |
| 1309 } | |
| 1310 /* same_user mapping already exists: idempotent no-op */ | |
| 1311 | |
| 1312 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0) | |
| 1313 { | |
| 1314 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1315 pthread_mutex_unlock(&p_store->mutex); | |
| 1316 return CONVERSATION_STORE_ERROR; | |
| 1317 } | |
| 1318 pthread_mutex_unlock(&p_store->mutex); | |
| 1319 return CONVERSATION_STORE_OK; | |
| 1320 } | |
| 1321 | |
| 1322 Conversation_Store_Result Conversation_Store_Transfer_Guest_To_User_Atomic( | |
| 1323 Conversation_Store *p_store, | |
| 1324 const char *guest_id, | |
| 1325 const char *user_id) | |
| 1326 { | |
| 1327 if (!p_store || !guest_id || !user_id || | |
| 1328 guest_id[0] == '\0' || user_id[0] == '\0') | |
| 1329 return CONVERSATION_STORE_ERROR; | |
| 1330 | |
| 1331 pthread_mutex_lock(&p_store->mutex); | |
| 1332 if (Deita_Query_Execute_Update( | |
| 1333 p_store->p_connection, "BEGIN IMMEDIATE") < 0) | |
| 1334 { | |
| 1335 pthread_mutex_unlock(&p_store->mutex); | |
| 1336 return CONVERSATION_STORE_ERROR; | |
| 1337 } | |
| 1338 | |
| 1339 /* Check for an existing mapping for this guest_id. */ | |
| 1340 Dowa_Arena *p_arena = Dowa_Arena_Create(512); | |
| 1341 if (!p_arena) | |
| 1342 { | |
| 1343 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1344 pthread_mutex_unlock(&p_store->mutex); | |
| 1345 return CONVERSATION_STORE_ERROR; | |
| 1346 } | |
| 1347 const char *check_params[] = {guest_id}; | |
| 1348 Deita_Result_Set *p_result = Deita_Query_Execute_Prepared( | |
| 1349 p_store->p_connection, | |
| 1350 "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?", | |
| 1351 1, check_params, p_arena); | |
| 1352 boolean mapping_exists = p_result && Deita_Result_Set_Next(p_result); | |
| 1353 const char *existing_user = mapping_exists | |
| 1354 ? Deita_Result_Set_Get_Text(p_result, 0) : NULL; | |
| 1355 boolean same_user = mapping_exists && existing_user && | |
| 1356 strcmp(existing_user, user_id) == 0; | |
| 1357 boolean conflict = mapping_exists && !same_user; | |
| 1358 if (p_result) | |
| 1359 Deita_Result_Set_Free(p_result); | |
| 1360 Dowa_Arena_Free(p_arena); | |
| 1361 | |
| 1362 if (conflict) | |
| 1363 { | |
| 1364 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT); | |
| 1365 pthread_mutex_unlock(&p_store->mutex); | |
| 1366 return CONVERSATION_STORE_CONFLICT; | |
| 1367 } | |
| 1368 | |
| 1369 if (!same_user) | |
| 1370 { | |
| 1371 const char *map_params[] = {guest_id, user_id}; | |
| 1372 if (Deita_Query_Execute_Update_Prepared( | |
| 1373 p_store->p_connection, | |
| 1374 "INSERT OR IGNORE INTO conversation_guest_transfers " | |
| 1375 "(guest_id, user_id) VALUES (?, ?)", | |
| 1376 2, map_params) < 0) | |
| 1377 { | |
| 1378 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1379 pthread_mutex_unlock(&p_store->mutex); | |
| 1380 return CONVERSATION_STORE_ERROR; | |
| 1381 } | |
| 1382 | |
| 1383 const char *transfer_params[] = {user_id, guest_id}; | |
| 1384 if (Deita_Query_Execute_Update_Prepared( | |
| 1385 p_store->p_connection, | |
| 1386 "UPDATE conversations SET owner_kind = 'user', owner_id = ?, " | |
| 1387 "updated_at = strftime('%s','now') " | |
| 1388 "WHERE owner_kind = 'guest' AND owner_id = ?", | |
| 1389 2, transfer_params) < 0) | |
| 1390 { | |
| 1391 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1392 pthread_mutex_unlock(&p_store->mutex); | |
| 1393 return CONVERSATION_STORE_ERROR; | |
| 1394 } | |
| 1395 } | |
| 1396 | |
| 1397 /* | |
| 1398 * Quota cleanup — auth tables reside in the same SQLite file so the write | |
| 1399 * lock already held by this transaction covers them too. Decrement | |
| 1400 * output_tokens_reserved and delete reservation rows for this guest. | |
| 1401 */ | |
| 1402 const char *quota_upd_params[] = {guest_id, guest_id}; | |
| 1403 if (Deita_Query_Execute_Update_Prepared( | |
| 1404 p_store->p_connection, | |
| 1405 "UPDATE guest_usage" | |
| 1406 " SET output_tokens_reserved = MAX(0, output_tokens_reserved - (" | |
| 1407 " SELECT COALESCE(SUM(r.output_tokens_reserved), 0)" | |
| 1408 " FROM guest_usage_reservations r" | |
| 1409 " WHERE r.guest_id = ? AND r.window_start = guest_usage.window_start" | |
| 1410 " ))" | |
| 1411 " WHERE guest_id = ?", | |
| 1412 2, quota_upd_params) < 0) | |
| 1413 { | |
| 1414 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1415 pthread_mutex_unlock(&p_store->mutex); | |
| 1416 return CONVERSATION_STORE_ERROR; | |
| 1417 } | |
| 1418 | |
| 1419 const char *quota_del_params[] = {guest_id}; | |
| 1420 if (Deita_Query_Execute_Update_Prepared( | |
| 1421 p_store->p_connection, | |
| 1422 "DELETE FROM guest_usage_reservations WHERE guest_id = ?", | |
| 1423 1, quota_del_params) < 0) | |
| 1424 { | |
| 1425 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1426 pthread_mutex_unlock(&p_store->mutex); | |
| 1427 return CONVERSATION_STORE_ERROR; | |
| 1428 } | |
| 1429 | |
| 1430 if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0) | |
| 1431 { | |
| 1432 Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR); | |
| 1433 pthread_mutex_unlock(&p_store->mutex); | |
| 1434 return CONVERSATION_STORE_ERROR; | |
| 1435 } | |
| 1436 pthread_mutex_unlock(&p_store->mutex); | |
| 1437 return CONVERSATION_STORE_OK; | |
| 1438 } |