diff --git a/src/application/ResourceVersionManager.h b/src/application/ResourceVersionManager.h index ddbb2c3..5f122e5 100644 --- a/src/application/ResourceVersionManager.h +++ b/src/application/ResourceVersionManager.h @@ -74,6 +74,7 @@ namespace TUI::Application { /** Lock first, then check. */ LockReadLock(resourcePath, id); + auto invalidationVersion = _states.at(resourcePath).invalidationVersion; std::weak_ptr> manager_ref = this->shared_from_this(); Lock lock{ [=]() @@ -81,7 +82,7 @@ namespace TUI::Application auto manager = manager_ref.lock(); if (manager) { - manager->ConfirmRead(resourcePath, id); + manager->ConfirmRead(resourcePath, id, invalidationVersion); } }, [=]() @@ -116,6 +117,7 @@ namespace TUI::Application Lock GetWriteLock(const std::vector& resourcePath, const ID& id) { LockWriteLock(resourcePath, id); + auto invalidationVersion = _states.at(resourcePath).invalidationVersion; std::weak_ptr> manager_ref = this->shared_from_this(); Lock lock{ [=]() @@ -123,7 +125,7 @@ namespace TUI::Application auto manager = manager_ref.lock(); if (manager) { - manager->ConfirmWrite(resourcePath, id); + manager->ConfirmWrite(resourcePath, id, invalidationVersion); } }, [=]() @@ -170,22 +172,37 @@ namespace TUI::Application return std::move(lock); } + void Invalidate(const std::vector& resourcePath) + { + auto item = _states.find(resourcePath); + if (item != _states.end()) + { + item->second.upToDateSet.clear(); + ++item->second.invalidationVersion; + } + } + private: struct ResourceState { std::unordered_set upToDateSet{}; std::unordered_set readLockHolders{}; std::optional writeLockHolder{}; + size_t invalidationVersion{0}; }; /** Stores the IDs that are up to date on the resource path (the key). */ std::map, ResourceState> _states{}; ResourceVersionManager() = default; - void ConfirmRead(const std::vector& resourcePath, const ID& id) + void ConfirmRead(const std::vector& resourcePath, const ID& id, size_t invalidationVersion) { /** This creates the entry if it does not exist. */ auto& state = _states[resourcePath]; + if (state.invalidationVersion != invalidationVersion) + { + return; + } if (state.upToDateSet.find(id) != state.upToDateSet.end()) { return; @@ -193,12 +210,15 @@ namespace TUI::Application state.upToDateSet.insert(id); }; - void ConfirmWrite(const std::vector& resourcePath, const ID& id) + void ConfirmWrite(const std::vector& resourcePath, const ID& id, size_t invalidationVersion) { /** This will make id the sole user that's up to date */ auto& state = _states[resourcePath]; state.upToDateSet.clear(); - state.upToDateSet.insert(id); + if (state.invalidationVersion == invalidationVersion) + { + state.upToDateSet.insert(id); + } }; /** diff --git a/src/application/Service.cpp b/src/application/Service.cpp index 4ee7c04..f24b3a6 100644 --- a/src/application/Service.cpp +++ b/src/application/Service.cpp @@ -25,6 +25,8 @@ Service::Service( {"getMetadata", std::bind(&Service::OnGetMetadataAsync, this, std::placeholders::_1, std::placeholders::_2)}, {"deleteMetadata", std::bind(&Service::OnDeleteMetadataAsync, this, std::placeholders::_1, std::placeholders::_2)}, {"getChatList", std::bind(&Service::OnGetChatListAsync, this, std::placeholders::_1, std::placeholders::_2)}, + {"getPinnedChatList", std::bind(&Service::OnGetPinnedChatListAsync, this, std::placeholders::_1, std::placeholders::_2)}, + {"setChatPinned", std::bind(&Service::OnSetChatPinnedAsync, this, std::placeholders::_1, std::placeholders::_2)}, {"newChat", std::bind(&Service::OnNewChatAsync, this, std::placeholders::_1, std::placeholders::_2)}, {"getChat", std::bind(&Service::OnGetChatAsync, this, std::placeholders::_1, std::placeholders::_2)}, {"deleteChat", std::bind(&Service::DeleteChatAsync, this, std::placeholders::_1, std::placeholders::_2)}, @@ -169,6 +171,7 @@ JS::Promise Service::OnSetMetadataAsync(CallerId callerId, nlohm co_await _database->SetChatMetadataAsync( callerId.userId, chatId, std::move(newMetadataString)); + InvalidateChatLists(callerId.userId); } else { @@ -355,6 +358,7 @@ JS::Promise Service::OnDeleteMetadataAsync(CallerId callerId, nl co_await _database->SetChatMetadataAsync( callerId.userId, chatId, std::move(newMetadataString)); + InvalidateChatLists(callerId.userId); } else { @@ -390,9 +394,36 @@ JS::Promise Service::OnGetChatListAsync(CallerId callerId, nlohm } auto list = _database->ListChat(callerId.userId, static_cast(start), static_cast(quantity)); + co_return static_cast(MakeChatListResult(list, params.get_meta_data_keys())); +} + +JS::Promise Service::OnGetPinnedChatListAsync(CallerId callerId, nlohmann::json paramsJson) +{ + auto params = ParseParams(paramsJson); + auto lock = _resourceVersionManager->GetReadLock( + {"pinnedChatList", static_cast(callerId.userId)}, callerId); + auto list = _database->ListPinnedChat(callerId.userId); + co_return static_cast(MakeChatListResult(list, params.get_meta_data_keys())); +} + +JS::Promise Service::OnSetChatPinnedAsync(CallerId callerId, nlohmann::json paramsJson) +{ + auto params = ParseParams(paramsJson); + Common::Uuid chatId{params.get_id()}; + if (!(co_await _database->SetChatPinnedAsync(callerId.userId, chatId, params.get_pinned()))) + { + throw Schema::Rpc::Exception(Schema::Rpc::ErrorCode::NOT_FOUND, "Chat not found"); + } + _resourceVersionManager->Invalidate({"pinnedChatList", static_cast(callerId.userId)}); + co_return nlohmann::json{}; +} + +Schema::IServer::GetChatListResult Service::MakeChatListResult( + const std::list& list, + const std::optional>& metadataKeys) +{ Schema::IServer::GetChatListResult result{}; result.reserve(list.size()); - auto metadataKeys = params.get_meta_data_keys(); for (const auto& item : list) { using EntryType = std::remove_reference::type::value_type; @@ -407,7 +438,14 @@ JS::Promise Service::OnGetChatListAsync(CallerId callerId, nlohm result.push_back(std::move(entry)); } result.shrink_to_fit(); - co_return static_cast(result); + return result; +} + +void Service::InvalidateChatLists(const Common::Uuid& userId) +{ + auto userIdString = static_cast(userId); + _resourceVersionManager->Invalidate({"chatList", userIdString}); + _resourceVersionManager->Invalidate({"pinnedChatList", userIdString}); } /** @@ -469,6 +507,7 @@ JS::Promise Service::DeleteChatAsync(CallerId callerId, nlohmann {"chat", static_cast(callerId.userId), static_cast(chatId)}, callerId); co_await _database->DeleteChatAsync(callerId.userId, chatId); + InvalidateChatLists(callerId.userId); /** Return null */ co_return nlohmann::json{}; } @@ -675,6 +714,7 @@ JS::AsyncGenerator Service::OnChatCompletionAsyn callerId.userId, chatId, std::move(allNodes[i]), i == 0); + InvalidateChatLists(callerId.userId); } } diff --git a/src/application/Service.h b/src/application/Service.h index d06d86f..4092953 100644 --- a/src/application/Service.h +++ b/src/application/Service.h @@ -48,6 +48,8 @@ namespace TUI::Application JS::Promise OnGetMetadataAsync(CallerId callerId, nlohmann::json params); JS::Promise OnDeleteMetadataAsync(CallerId callerId, nlohmann::json params); JS::Promise OnGetChatListAsync(CallerId callerId, nlohmann::json params); + JS::Promise OnGetPinnedChatListAsync(CallerId callerId, nlohmann::json params); + JS::Promise OnSetChatPinnedAsync(CallerId callerId, nlohmann::json params); JS::Promise OnNewChatAsync(CallerId callerId, nlohmann::json params); JS::Promise OnGetChatAsync(CallerId callerId, nlohmann::json params); JS::Promise DeleteChatAsync(CallerId callerId, nlohmann::json params); @@ -92,6 +94,10 @@ namespace TUI::Application } std::shared_ptr GetProvider(const Common::Uuid& providerId); + Schema::IServer::GetChatListResult MakeChatListResult( + const std::list& list, + const std::optional>& metadataKeys); + void InvalidateChatLists(const Common::Uuid& userId); std::map TryGetMetadata(const std::vector& keys, const std::string& metadataString); std::string TryMergeMetadata(const std::string& base, std::map& changes); std::string TryDeleteMetadata(const std::string& base, const std::vector& keys); diff --git a/src/database/Database.cpp b/src/database/Database.cpp index 3d7645c..879dece 100644 --- a/src/database/Database.cpp +++ b/src/database/Database.cpp @@ -55,6 +55,7 @@ JS::Promise> Database::CreateAsync( "user_id TEXT, " "id TEXT, " "metadata TEXT, " + "pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)), " "PRIMARY KEY (user_id, id));"); co_await db->_db->ExecAsync( "CREATE TABLE IF NOT EXISTS chat_content (" @@ -66,6 +67,21 @@ JS::Promise> Database::CreateAsync( "message TEXT, " "timestamp INTEGER, " "PRIMARY KEY (user_id, chat_id, id));"); + if (db->_db->Exec("SELECT name FROM pragma_table_info('chat') WHERE name = 'pinned';").empty()) + { + co_await db->_db->ExecAsync("BEGIN IMMEDIATE;"); + co_await db->_db->ExecAsync( + "ALTER TABLE chat ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1));"); + co_await db->_db->ExecAsync( + "UPDATE chat SET timestamp = COALESCE(" + "(SELECT MAX(timestamp) FROM chat_content WHERE user_id = chat.user_id AND chat_id = chat.id), " + "timestamp);"); + co_await db->_db->ExecAsync("COMMIT;"); + } + co_await db->_db->ExecAsync( + "CREATE INDEX IF NOT EXISTS chat_activity ON chat (user_id, timestamp DESC, id DESC);"); + co_await db->_db->ExecAsync( + "CREATE INDEX IF NOT EXISTS chat_pinned_activity ON chat (user_id, timestamp DESC, id DESC) WHERE pinned = 1;"); co_await db->_db->ExecAsync( "CREATE TABLE IF NOT EXISTS file_meta (" "user_id TEXT, " @@ -372,12 +388,28 @@ std::list Database::ListChat( const Uuid& userId, size_t from , size_t limit) { auto sql = std::format( - "SELECT id, metadata FROM chat WHERE user_id = ? ORDER BY timestamp DESC LIMIT {} OFFSET {}", + "SELECT id, metadata FROM chat WHERE user_id = ? ORDER BY timestamp DESC, id DESC LIMIT {} OFFSET {}", limit, from); auto result = _db->Exec(sql, static_cast(userId)); return ParseListTableIdWithMetadataResult(result); } +std::list Database::ListPinnedChat(const Uuid& userId) +{ + auto result = _db->Exec( + "SELECT id, metadata FROM chat WHERE user_id = ? AND pinned = 1 ORDER BY timestamp DESC, id DESC;", + static_cast(userId)); + return ParseListTableIdWithMetadataResult(result); +} + +JS::Promise Database::SetChatPinnedAsync(const Uuid& userId, const Uuid& chatId, bool pinned) +{ + auto result = co_await _db->ExecAsync( + "UPDATE chat SET pinned = ? WHERE user_id = ? AND id = ? RETURNING id;", + static_cast(pinned), static_cast(userId), static_cast(chatId)); + co_return !result.empty(); +} + JS::Promise Database::SetChatMetadataAsync(const Uuid& userId, const Uuid& id, std::string metadata) { return SetStringToChatAsync(userId, id, "metadata", metadata); @@ -444,6 +476,10 @@ JS::Promise Database::AppendChatHistoryAsync( (static_cast(node.get_children())).dump(), (static_cast(node.get_message())).dump(), static_cast(node.get_timestamp())); + co_await _db->ExecAsync( + "UPDATE chat SET timestamp = ? WHERE user_id = ? AND id = ?;", + Timestamp::GetWallClock(), + static_cast(userId), static_cast(chatId)); } IServer::TreeHistory Database::GetChatHistory(const Uuid& userId, const Uuid& id) @@ -602,12 +638,11 @@ JS::Promise Database::SetStringToChatAsync( const Uuid& userId, const Uuid& id, const std::string& name, std::string value) { auto sql = std::format( - "UPDATE chat SET {} = ?, timestamp = ? WHERE user_id = ? AND id = ?;", + "UPDATE chat SET {} = ? WHERE user_id = ? AND id = ?;", name); co_await _db->ExecAsync( sql, std::move(value), - Timestamp::GetWallClock(), static_cast(userId), static_cast(id)); } diff --git a/src/database/Database.h b/src/database/Database.h index 2876ea4..f2fba14 100644 --- a/src/database/Database.h +++ b/src/database/Database.h @@ -81,6 +81,9 @@ namespace TUI::Database size_t GetChatCount(const Common::Uuid& userId); std::list ListChat( const Common::Uuid& userId, size_t from = 0, size_t limit = 50); + std::list ListPinnedChat(const Common::Uuid& userId); + JS::Promise SetChatPinnedAsync( + const Common::Uuid& userId, const Common::Uuid& chatId, bool pinned); JS::Promise SetChatMetadataAsync( const Common::Uuid& userId, const Common::Uuid& chatId, std::string metadata); std::string GetChatMetadata(const Common::Uuid& userId, const Common::Uuid& chatId); diff --git a/src/schema/IServer.h b/src/schema/IServer.h index 1481948..fb15535 100644 --- a/src/schema/IServer.h +++ b/src/schema/IServer.h @@ -406,6 +406,37 @@ namespace IServer { void set_metadata(std::optional> value) { this->metadata = value; } }; + class GetPinnedChatListParams { + public: + GetPinnedChatListParams() = default; + virtual ~GetPinnedChatListParams() = default; + + private: + std::optional> meta_data_keys; + + public: + std::optional> get_meta_data_keys() const { return meta_data_keys; } + void set_meta_data_keys(std::optional> value) { this->meta_data_keys = value; } + }; + + class SetChatPinnedParams { + public: + SetChatPinnedParams() = default; + virtual ~SetChatPinnedParams() = default; + + private: + std::string id; + bool pinned{false}; + + public: + const std::string & get_id() const { return id; } + std::string & get_mutable_id() { return id; } + void set_id(const std::string & value) { this->id = value; } + + bool get_pinned() const { return pinned; } + void set_pinned(bool value) { this->pinned = value; } + }; + class Tool { public: Tool() = default; @@ -945,6 +976,7 @@ namespace IServer { using GetMetadataResult = std::map; using LinearHistory = std::vector; using GetChatListResult = std::vector; + using GetPinnedChatListResult = GetChatListResult; using GetModelListResult = std::vector; using GetUserListResult = std::vector; using ListFileResult = std::vector; @@ -988,6 +1020,12 @@ void to_json(json & j, const GetChatListParams & x); void from_json(const json & j, GetChatListResultElement & x); void to_json(json & j, const GetChatListResultElement & x); +void from_json(const json & jsonValue, GetPinnedChatListParams & value); +void to_json(json & jsonValue, const GetPinnedChatListParams & value); + +void from_json(const json & jsonValue, SetChatPinnedParams & value); +void to_json(json & jsonValue, const SetChatPinnedParams & value); + void from_json(const json & j, Tool & x); void to_json(json & j, const Tool & x); @@ -1248,6 +1286,28 @@ namespace IServer { } } + inline void from_json(const json & jsonValue, GetPinnedChatListParams & value) { + value.set_meta_data_keys(get_stack_optional>(jsonValue, "metaDataKeys")); + } + + inline void to_json(json & jsonValue, const GetPinnedChatListParams & value) { + jsonValue = json::object(); + if (value.get_meta_data_keys()) { + jsonValue["metaDataKeys"] = value.get_meta_data_keys(); + } + } + + inline void from_json(const json & jsonValue, SetChatPinnedParams & value) { + value.set_id(jsonValue.at("id").get()); + value.set_pinned(jsonValue.at("pinned").get()); + } + + inline void to_json(json & jsonValue, const SetChatPinnedParams & value) { + jsonValue = json::object(); + jsonValue["id"] = value.get_id(); + jsonValue["pinned"] = value.get_pinned(); + } + inline void from_json(const json & j, Tool& x) { x.set_description(j.at("description").get()); x.set_name(j.at("name").get()); diff --git a/src/schema/src/types b/src/schema/src/types index aa233cb..5ac2fea 160000 --- a/src/schema/src/types +++ b/src/schema/src/types @@ -1 +1 @@ -Subproject commit aa233cb6777dc9936c8e376dc8187822aa8cc4df +Subproject commit 5ac2fea7bad4307cbf930e77b157cf23dcd66a7a diff --git a/test/ServiceClient/TestChatLists.js b/test/ServiceClient/TestChatLists.js new file mode 100644 index 0000000..445db3c --- /dev/null +++ b/test/ServiceClient/TestChatLists.js @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { TUIClient } from './TUIClient.js'; + +const provider = createServer((request, response) => { + request.resume(); + request.on('end', () => { + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.end(`data: ${JSON.stringify({ choices: [{ delta: { content: 'Saved reply' } }] })}\n\ndata: [DONE]\n\n`); + }); +}); +provider.listen(0, '127.0.0.1'); +await once(provider, 'listening'); + +const writer = new TUIClient('127.0.0.1', 12345, console.error); +const reader = new TUIClient('127.0.0.1', 12345, console.error); +const listParams = { start: 0, quantity: 50, metaDataKeys: ['title'] }; +const pinParams = { metaDataKeys: ['title'] }; +const ids = list => list.map(chat => chat.id); +const hasCode = code => error => error.code === code; + +try { + await writer.connectAsync(); + await reader.connectAsync(); + assert.deepEqual(await writer.makeRequestAsync('getChatList', listParams), []); + const first = await writer.makeRequestAsync('newChat', {}); + const second = await writer.makeRequestAsync('newChat', {}); + await writer.makeRequestAsync('setMetadata', { path: ['chat', first], entries: { title: 'First' } }); + await writer.makeRequestAsync('setMetadata', { path: ['chat', second], entries: { title: 'Second' } }); + const initial = await writer.makeRequestAsync('getChatList', listParams); + assert.deepEqual(await reader.makeRequestAsync('getChatList', listParams), initial); + assert.deepEqual(await writer.makeRequestAsync('getPinnedChatList', pinParams), []); + assert.deepEqual(await reader.makeRequestAsync('getPinnedChatList', pinParams), []); + + await writer.makeRequestAsync('setChatPinned', { id: first, pinned: true }); + const firstPin = [{ id: first, metadata: { title: 'First' } }]; + assert.deepEqual(await writer.makeRequestAsync('getPinnedChatList', pinParams), firstPin); + assert.deepEqual(await reader.makeRequestAsync('getPinnedChatList', pinParams), firstPin); + await assert.rejects(reader.makeRequestAsync('getChatList', listParams), hasCode(304)); + await writer.makeRequestAsync('setChatPinned', { id: second, pinned: true }); + assert.deepEqual(ids(await writer.makeRequestAsync('getPinnedChatList', pinParams)), ids(initial)); + assert.deepEqual(ids(await reader.makeRequestAsync('getPinnedChatList', pinParams)), ids(initial)); + + await writer.makeRequestAsync('getModelList', {}); + const modelId = await writer.makeRequestAsync('newModel', { + providerName: 'AzureOpenAI', + providerParams: { url: `http://127.0.0.1:${provider.address().port}`, apiKey: 'test-only' }, + }); + const updatedId = initial.at(-1).id; + const completionParams = { + id: updatedId, + modelId, + messages: [{ role: 'user', content: [{ type: 'text', data: 'Update this chat' }] }], + }; + let reply = ''; + for await (const segment of writer.makeStreamRequestAsync('chatCompletion', completionParams)) { + reply += segment; + } + assert.equal(reply, 'Saved reply'); + await assert.rejects(reader.makeRequestAsync('getChatList', { ...listParams, start: 1 }), hasCode(409)); + assert.equal((await writer.makeRequestAsync('getChatList', listParams))[0].id, updatedId); + assert.equal((await reader.makeRequestAsync('getChatList', listParams))[0].id, updatedId); + assert.equal((await writer.makeRequestAsync('getPinnedChatList', pinParams))[0].id, updatedId); + assert.equal((await reader.makeRequestAsync('getPinnedChatList', pinParams))[0].id, updatedId); + + await assert.rejects(async () => { + for await (const segment of writer.makeStreamRequestAsync('chatCompletion', { ...completionParams, parent: randomUUID() })) { + void segment; + } + }, hasCode(404)); + await assert.rejects(reader.makeRequestAsync('getChatList', listParams), hasCode(304)); + await assert.rejects(reader.makeRequestAsync('getPinnedChatList', pinParams), hasCode(304)); + + await writer.makeRequestAsync('setMetadata', { path: ['chat', updatedId], entries: { title: 'Renamed' } }); + assert.equal((await writer.makeRequestAsync('getChatList', listParams))[0].id, updatedId); + assert.equal((await reader.makeRequestAsync('getPinnedChatList', pinParams))[0].metadata.title, 'Renamed'); + await writer.makeRequestAsync('setChatPinned', { id: updatedId, pinned: false }); + assert.equal((await writer.makeRequestAsync('getPinnedChatList', pinParams)).length, 1); + await writer.makeRequestAsync('deleteChat', updatedId === first ? second : first); + assert.deepEqual(await reader.makeRequestAsync('getPinnedChatList', pinParams), []); + await assert.rejects(writer.makeRequestAsync('setChatPinned', { id: randomUUID(), pinned: true }), hasCode(404)); + await assert.rejects(writer.makeRequestAsync('setChatPinned', { id: first, pinned: 'true' }), hasCode(400)); + console.log('Chat list RPC checks passed: pin/unpin, activity order, failures, metadata, deletion, and cross-client invalidation.'); +} finally { + writer.close(); + reader.close(); + await new Promise(resolve => provider.close(resolve)); +} \ No newline at end of file diff --git a/test/TestDatabase.cpp b/test/TestDatabase.cpp index b61e9f0..9b03e0c 100644 --- a/test/TestDatabase.cpp +++ b/test/TestDatabase.cpp @@ -25,6 +25,42 @@ void TestClose() db = nullptr; } +JS::Promise TestChatMigrationAsync() +{ + auto legacyPath = dbPath + ".legacy"; + auto rawDb = co_await Sqlite::CreateAsync(tev, legacyPath); + co_await rawDb->ExecAsync( + "CREATE TABLE chat (timestamp INTEGER, user_id TEXT, id TEXT, metadata TEXT, PRIMARY KEY (user_id, id));"); + co_await rawDb->ExecAsync( + "CREATE TABLE chat_content (user_id TEXT, chat_id TEXT, id TEXT, parent TEXT, " + "children TEXT, message TEXT, timestamp INTEGER, PRIMARY KEY (user_id, chat_id, id));"); + Uuid userId{}; + Uuid chatId{}; + Uuid emptyChatId{}; + co_await rawDb->ExecAsync( + "INSERT INTO chat (timestamp, user_id, id, metadata) VALUES (100, ?, ?, '{}'), (50, ?, ?, '{}');", + static_cast(userId), static_cast(chatId), + static_cast(userId), static_cast(emptyChatId)); + co_await rawDb->ExecAsync( + "INSERT INTO chat_content (user_id, chat_id, id, timestamp) VALUES (?, ?, 'message', 30);", + static_cast(userId), static_cast(chatId)); + + auto migrated = co_await Database::CreateAsync(tev, legacyPath, fileRoot); + AssertWithMessage(migrated->ListPinnedChat(userId).empty(), "Existing chats should start unpinned"); + AssertWithMessage(migrated->ListChat(userId).front().id == emptyChatId, + "Migration should restore message activity order and preserve empty chats"); + auto timestamps = rawDb->Exec("SELECT timestamp FROM chat ORDER BY timestamp;"); + AssertWithMessage(std::get(timestamps.front().at("timestamp")) == 30, + "Migration should use the latest message timestamp"); + AssertWithMessage(std::get(timestamps.back().at("timestamp")) == 50, + "Migration should preserve timestamps for chats without messages"); + co_await migrated->SetChatPinnedAsync(userId, chatId, true); + migrated.reset(); + migrated = co_await Database::CreateAsync(tev, legacyPath, fileRoot); + AssertWithMessage(migrated->ListPinnedChat(userId).front().id == chatId, + "Pin state should survive reopening an upgraded database"); +} + JS::Promise TestGlobalAsync() { std::string key = "test-key"; @@ -170,6 +206,28 @@ JS::Promise TestChatAsync() auto metadata = db->GetChatMetadata(userId, chatId); AssertWithMessage(metadata == "test-chat-metadata", "Chat metadata should match"); { + auto newerChatId = co_await db->CreateChatAsync(userId); + auto rawDb = co_await Sqlite::CreateAsync(tev, dbPath); + co_await rawDb->ExecAsync( + "UPDATE chat SET timestamp = CASE WHEN id = ? THEN 1 ELSE 2 END WHERE user_id = ?;", + static_cast(chatId), static_cast(userId)); + co_await db->SetChatMetadataAsync(userId, chatId, "renamed-chat"); + AssertWithMessage(db->ListChat(userId).front().id == newerChatId, + "Metadata writes should not reorder chats"); + AssertWithMessage(db->ListPinnedChat(userId).empty(), "New chats should start unpinned"); + AssertWithMessage(co_await db->SetChatPinnedAsync(userId, chatId, true), "Pin should find the chat"); + co_await db->SetChatPinnedAsync(userId, newerChatId, true); + co_await db->SetChatPinnedAsync(userId, chatId, true); + AssertWithMessage(db->ListPinnedChat(userId).size() == 2, "Pinning should be idempotent"); + AssertWithMessage(db->ListPinnedChat(userId).front().id == newerChatId, + "Pinned chats should use activity order rather than pin order"); + AssertWithMessage(db->ListChat(userId).front().id == newerChatId, + "Pinning should not change activity order"); + Uuid otherUserId{}; + AssertWithMessage(db->ListPinnedChat(otherUserId).empty(), "Pins should be scoped to the user"); + AssertWithMessage(!(co_await db->SetChatPinnedAsync(otherUserId, chatId, false)), + "Other users must not modify a chat's pin"); + IServer::ChatMessage chatMsg0{}; chatMsg0.set_role(IServer::ChatMessageRole::USER); IServer::MessageContent content0{}; @@ -181,6 +239,10 @@ JS::Promise TestChatAsync() node0.set_timestamp(1.0); node0.set_message(std::move(chatMsg0)); co_await db->AppendChatHistoryAsync(userId, chatId, node0); + AssertWithMessage(db->ListChat(userId).front().id == chatId, + "Successful message writes should move a chat to the top"); + AssertWithMessage(db->ListPinnedChat(userId).front().id == chatId, + "Successful message writes should reorder pinned chats too"); IServer::MessageNode node1{}; node1.set_id("node1"); @@ -220,6 +282,36 @@ JS::Promise TestChatAsync() AssertWithMessage(retrievedNode1.get_parent().has_value() == true, "Node1 parent should not be null"); AssertWithMessage(retrievedNode1.get_parent().value() == "node0", "Node1 parent ID should match"); AssertWithMessage(retrievedNode1.get_children().empty(), "Node1 should have no children"); + co_await rawDb->ExecAsync( + "UPDATE chat SET timestamp = 5 WHERE user_id = ?;", static_cast(userId)); + auto expectedFirst = static_cast(chatId) > static_cast(newerChatId) + ? chatId : newerChatId; + AssertWithMessage(db->ListChat(userId, 0, 1).front().id == expectedFirst, + "Equal activity timestamps should have deterministic ordering"); + AssertWithMessage(db->ListPinnedChat(userId).front().id == expectedFirst, + "Both lists should use the same tie-breaker"); + AssertWithMessage(db->ListChat(userId, 1, 1).front().id != expectedFirst, + "Pagination should not duplicate chats with equal timestamps"); + co_await rawDb->ExecAsync( + "UPDATE chat SET timestamp = CASE WHEN id = ? THEN 1 ELSE 2 END WHERE user_id = ?;", + static_cast(chatId), static_cast(userId)); + bool rejected = false; + try + { + co_await db->AppendChatHistoryAsync(userId, chatId, node0); + } + catch (const std::exception&) + { + rejected = true; + } + AssertWithMessage(rejected, "Duplicate message writes should fail"); + AssertWithMessage(db->ListChat(userId).front().id == newerChatId, + "Failed message writes should not change activity order"); + co_await db->SetChatPinnedAsync(userId, chatId, false); + co_await db->SetChatPinnedAsync(userId, chatId, false); + AssertWithMessage(db->ListPinnedChat(userId).size() == 1, "Unpinning should be idempotent"); + co_await db->DeleteChatAsync(userId, newerChatId); + AssertWithMessage(db->ListPinnedChat(userId).empty(), "Deleting a chat should remove its pin"); } size_t count = db->GetChatCount(userId); AssertWithMessage(count == 1, "Chat count should be 1"); @@ -351,6 +443,7 @@ JS::Promise TestAsync() { /** Always run this first */ RunAsyncTest(TestCreateAsync()); + RunAsyncTest(TestChatMigrationAsync()); RunAsyncTest(TestGlobalAsync()); RunAsyncTest(TestModelAsync()); RunAsyncTest(TestUserAsync()); diff --git a/test/TestIServerSchema.cpp b/test/TestIServerSchema.cpp index 89b7694..b11491a 100644 --- a/test/TestIServerSchema.cpp +++ b/test/TestIServerSchema.cpp @@ -370,6 +370,43 @@ static void TestChatCompletionSegmentObjectVariant() AssertWithMessage(output == objInput, "Round-trip should produce identical JSON"); } +static void TestPinnedChatListRoundTrip() +{ + auto input = R"({"metaDataKeys":["title"]})"_json; + auto params = input.get(); + AssertWithMessage(json(params) == input, "Pinned list parameters should round-trip"); + auto empty = json::object().get(); + AssertWithMessage(!empty.get_meta_data_keys().has_value(), "Metadata keys should be optional"); + auto list = R"([{"id":"chat","metadata":{"title":"Pinned"}}])"_json; + AssertWithMessage(json(list.get()) == list, + "Pinned results should use the same shape as the regular chat list"); +} + +static void TestSetChatPinnedValidation() +{ + for (bool pinned : {false, true}) + { + json input = {{"id", "chat"}, {"pinned", pinned}}; + auto params = input.get(); + AssertWithMessage(params.get_pinned() == pinned, "Pin state should be preserved"); + AssertWithMessage(json(params) == input, "Pin parameters should round-trip"); + } + for (const auto& input : {R"({"id":"chat"})"_json, R"({"pinned":true})"_json, + R"({"id":"chat","pinned":"true"})"_json}) + { + bool rejected = false; + try + { + input.get(); + } + catch (const json::exception&) + { + rejected = true; + } + AssertWithMessage(rejected, "Invalid pin parameters should be rejected"); + } +} + int main() { struct { const char* name; void (*fn)(); } tests[] = { @@ -389,6 +426,8 @@ int main() {"TreeHistoryRoundTrip", TestTreeHistoryRoundTrip}, {"ChatCompletionSegmentStringVariant", TestChatCompletionSegmentStringVariant}, {"ChatCompletionSegmentObjectVariant", TestChatCompletionSegmentObjectVariant}, + {"PinnedChatListRoundTrip", TestPinnedChatListRoundTrip}, + {"SetChatPinnedValidation", TestSetChatPinnedValidation}, }; int passed = 0; diff --git a/test/TestResourceVersionManager.cpp b/test/TestResourceVersionManager.cpp index c89abbd..13403ee 100644 --- a/test/TestResourceVersionManager.cpp +++ b/test/TestResourceVersionManager.cpp @@ -169,6 +169,47 @@ static void TestDelete() auto lock = manager->GetReadLock({"test", "resource"}, "1"); } +static void TestInvalidation() +{ + auto manager = TUI::Application::ResourceVersionManager::Create(); + for (const auto& caller : {"1", "2"}) + { + auto lock = manager->GetReadLock({"chatList", "user"}, caller); + } + manager->Invalidate({"chatList", "user"}); + try + { + auto lock = manager->GetReadLock({"chatList", "user"}, "1", true); + AssertWithMessage(false, "Invalidated pagination should require refreshing the first page"); + } + catch (const TUI::Schema::Rpc::Exception& error) + { + AssertWithMessage(error.get_code() == TUI::Schema::Rpc::ErrorCode::CONFLICT, + "Invalidated pagination should report CONFLICT"); + } + for (const auto& caller : {"1", "2"}) + { + auto lock = manager->GetReadLock({"chatList", "user"}, caller); + } +} + +static void TestInvalidationDuringOperation() +{ + auto manager = TUI::Application::ResourceVersionManager::Create(); + { + auto lock = manager->GetReadLock({"chatList", "user"}, "1"); + manager->Invalidate({"chatList", "user"}); + } + { + auto lock = manager->GetReadLock({"chatList", "user"}, "1"); + } + { + auto lock = manager->GetWriteLock({"chatList", "user"}, "1"); + manager->Invalidate({"chatList", "user"}); + } + auto lock = manager->GetReadLock({"chatList", "user"}, "1"); +} + int main(int argc, char const *argv[]) { (void)argc; @@ -185,6 +226,8 @@ int main(int argc, char const *argv[]) RunTest(TestDoNotConfirm()); RunTest(TestNoConfirmationOnException()); RunTest(TestDelete()); + RunTest(TestInvalidation()); + RunTest(TestInvalidationDuringOperation()); return 0; }