From e07aca3c737ad2758edaff052344b0203035db81 Mon Sep 17 00:00:00 2001 From: spencerduberry Date: Thu, 11 Dec 2025 18:04:36 +0000 Subject: [PATCH 1/4] #169: create supabase profiles repository --- .../profiles/SupabaseProfilesRepository.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt diff --git a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt new file mode 100644 index 0000000..2339548 --- /dev/null +++ b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt @@ -0,0 +1,46 @@ +package chatserver.profiles + +import chatserver.ProfileResult +import chatserver.ReadChatRepository +import chatserver.SubscribeChatRepository +import chatserver.WriteChatRepository +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.postgrest.from +import io.github.jan.supabase.realtime.selectAsFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +class SupabaseProfilesRepository( + private val client: SupabaseClient, + private val scope: CoroutineScope, +): ReadChatRepository, WriteChatRepository, SubscribeChatRepository { + private val _profiles: MutableStateFlow = MutableStateFlow(ProfileResult.ok(emptyMap())) + override fun latest(): ProfileResult = _profiles.value + + override fun observe(): Flow = _profiles.asStateFlow() + + override fun write(item: String) { + TODO("Not yet implemented") + } + + override fun subscribe() { + client.from("profiles") + .selectAsFlow(Profile::id) + .map { it.associateBy(Profile::id) } + } + + override fun unsubscribe() { + scope.cancel() + } + @Serializable + data class Profile( + @SerialName("id") val id: String, + @SerialName("username") val username: String, + ) +} From 1cd98a7357095d8f02a4f2cd5638d3298cf6bda0 Mon Sep 17 00:00:00 2001 From: spencerduberry Date: Thu, 18 Dec 2025 17:52:17 +0000 Subject: [PATCH 2/4] #169: finish subscribe for SupabaseProfilesRepository --- src/main/kotlin/chatserver/ProfileResult.kt | 7 ++++--- .../profiles/LocalProfilesRepository.kt | 5 +++-- .../profiles/SupabaseProfilesRepository.kt | 21 ++++++++++++++----- .../kotlin/profile/ProfilePresenterTest.kt | 5 +++-- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/chatserver/ProfileResult.kt b/src/main/kotlin/chatserver/ProfileResult.kt index 2dca804..633c84c 100644 --- a/src/main/kotlin/chatserver/ProfileResult.kt +++ b/src/main/kotlin/chatserver/ProfileResult.kt @@ -1,15 +1,16 @@ package chatserver import chatserver.ReadChatRepository.ReadResult +import chatserver.profiles.SupabaseProfilesRepository.Profile data class ProfileResult( override val isOk: Boolean, - override val item: Map, + override val item: Map, override val error: Exception?, ) : - ReadResult> { + ReadResult> { companion object { - fun ok(item: Map): ProfileResult = ProfileResult(true, item, null) + fun ok(item: Map): ProfileResult = ProfileResult(true, item, null) fun fail(e: Exception? = null): ProfileResult = ProfileResult(false, emptyMap(), e) } diff --git a/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt index 759ad0b..cccaa90 100644 --- a/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt +++ b/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt @@ -7,6 +7,7 @@ import chatserver.ReadChatRepository import chatserver.SubscribeChatRepository import chatserver.WriteChatRepository import chatserver.messages.LocalChatMessages +import chatserver.profiles.SupabaseProfilesRepository.Profile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -33,7 +34,7 @@ class LocalProfilesRepository( scope.launch(dispatchers.default) { while (true) { val users = LocalChatMessages.sampleUsers.shuffled() - state.value = users.associateWith { it }.let(ProfileResult::ok) + state.value = users.associateWith { Profile(it, it) }.let(ProfileResult::ok) delay(5.seconds) } } @@ -50,7 +51,7 @@ class LocalProfilesRepository( delay(2.seconds) check(item.isValidUsername()) { "Could not assign current username." } val profiles = latest().item.toMutableMap() - profiles[item] = item + profiles[item] = Profile(item, item) state.value = ok(profiles) } catch (e: Exception) { state.value = ProfileResult.fail(e) diff --git a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt index 2339548..4b42be8 100644 --- a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt +++ b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt @@ -5,6 +5,7 @@ import chatserver.ReadChatRepository import chatserver.SubscribeChatRepository import chatserver.WriteChatRepository import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.annotations.SupabaseExperimental import io.github.jan.supabase.postgrest.from import io.github.jan.supabase.realtime.selectAsFlow import kotlinx.coroutines.CoroutineScope @@ -12,32 +13,42 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable class SupabaseProfilesRepository( private val client: SupabaseClient, private val scope: CoroutineScope, -): ReadChatRepository, WriteChatRepository, SubscribeChatRepository { - private val _profiles: MutableStateFlow = MutableStateFlow(ProfileResult.ok(emptyMap())) - override fun latest(): ProfileResult = _profiles.value +) : ReadChatRepository, WriteChatRepository, SubscribeChatRepository { + private val profiles: MutableStateFlow = MutableStateFlow(ProfileResult.ok(emptyMap())) - override fun observe(): Flow = _profiles.asStateFlow() + override fun latest(): ProfileResult = profiles.value + + override fun observe(): Flow = profiles.asStateFlow() override fun write(item: String) { TODO("Not yet implemented") } + @OptIn(SupabaseExperimental::class) override fun subscribe() { client.from("profiles") .selectAsFlow(Profile::id) - .map { it.associateBy(Profile::id) } + .map { it.associateBy(Profile::id) } + .map { ProfileResult.ok(it) } + .onEach { profiles.value = it } + .catch { println(it) } + .launchIn(scope) } override fun unsubscribe() { scope.cancel() } + @Serializable data class Profile( @SerialName("id") val id: String, diff --git a/src/test/kotlin/profile/ProfilePresenterTest.kt b/src/test/kotlin/profile/ProfilePresenterTest.kt index 4c33f01..2ef1378 100644 --- a/src/test/kotlin/profile/ProfilePresenterTest.kt +++ b/src/test/kotlin/profile/ProfilePresenterTest.kt @@ -4,6 +4,7 @@ import arch.RokyDispatchers import chatserver.ProfileResult import chatserver.ReadChatRepository import chatserver.WriteChatRepository +import chatserver.profiles.SupabaseProfilesRepository.Profile import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -43,13 +44,13 @@ class ProfilePresenterTest { } val writeUsername: WriteChatRepository = mockk() every { writeUsername.write(any()) } answers { - val user = it.invocation.args [0] as String + val user = it.invocation.args[0] as String usernames.value = if (user == INVALID_USER) { ProfileResult.fail(RuntimeException()) } else { val currentUsernames = usernames.value.item.toMutableMap() - currentUsernames[user] = user + currentUsernames[user] = Profile(user, user) ProfileResult.ok(currentUsernames) } } From e1981e7b3135b1284a9381898012bdc82797498a Mon Sep 17 00:00:00 2001 From: spencerduberry Date: Thu, 8 Jan 2026 19:46:45 +0000 Subject: [PATCH 3/4] #169: feature write func for SupabaseProfileRepository --- src/main/kotlin/chatserver/ChatServer.kt | 1 + src/main/kotlin/chatserver/LoggedInUserId.kt | 8 ++++ .../profiles/SupabaseProfilesRepository.kt | 38 ++++++++++++------- 3 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 src/main/kotlin/chatserver/LoggedInUserId.kt diff --git a/src/main/kotlin/chatserver/ChatServer.kt b/src/main/kotlin/chatserver/ChatServer.kt index 2dfc874..9ccedf0 100644 --- a/src/main/kotlin/chatserver/ChatServer.kt +++ b/src/main/kotlin/chatserver/ChatServer.kt @@ -10,4 +10,5 @@ val chatServerModule = module { includes(chatServerProfilesModule, chatServerMessagesModule, chatServerPresenceModule) factoryOf(::ChatRepositories) + factoryOf(::LoggedInUserId) } diff --git a/src/main/kotlin/chatserver/LoggedInUserId.kt b/src/main/kotlin/chatserver/LoggedInUserId.kt new file mode 100644 index 0000000..249e0f7 --- /dev/null +++ b/src/main/kotlin/chatserver/LoggedInUserId.kt @@ -0,0 +1,8 @@ +package chatserver + +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.auth.auth + +class LoggedInUserId(private val client: SupabaseClient) { + operator fun invoke(): String = client.auth.currentUserOrNull()?.id.orEmpty() +} diff --git a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt index 4b42be8..14a7055 100644 --- a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt +++ b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt @@ -1,27 +1,20 @@ package chatserver.profiles -import chatserver.ProfileResult -import chatserver.ReadChatRepository -import chatserver.SubscribeChatRepository -import chatserver.WriteChatRepository +import chatserver.* import io.github.jan.supabase.SupabaseClient import io.github.jan.supabase.annotations.SupabaseExperimental import io.github.jan.supabase.postgrest.from import io.github.jan.supabase.realtime.selectAsFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable class SupabaseProfilesRepository( private val client: SupabaseClient, + private val userId: LoggedInUserId, private val scope: CoroutineScope, ) : ReadChatRepository, WriteChatRepository, SubscribeChatRepository { private val profiles: MutableStateFlow = MutableStateFlow(ProfileResult.ok(emptyMap())) @@ -30,8 +23,27 @@ class SupabaseProfilesRepository( override fun observe(): Flow = profiles.asStateFlow() - override fun write(item: String) { - TODO("Not yet implemented") + override fun write(requestedUsername: String) { + if(requestedUsername.isBlank()){ + throw IllegalArgumentException("Requested username must not be blank.") + } + val id = userId().ifBlank { + throw IllegalStateException("User ID must not be blank.") + } + val currentUsername = latest().item[id]?.username + if(currentUsername == requestedUsername){ + throw IllegalStateException("Current username must not match requested username.") + } + scope.launch { + client.from("profiles") + .update({ + set("username", requestedUsername) + }){ + filter{ + eq("id", id) + } + } + } } @OptIn(SupabaseExperimental::class) From 26a1d2f35a1cc8c33d20406e5cb5dbd1ac849542 Mon Sep 17 00:00:00 2001 From: sorellla Date: Thu, 22 Jan 2026 19:44:07 +0100 Subject: [PATCH 4/4] #169: add exception handling to SupabaseProfileRepository write function --- .../chatserver/profiles/ChatServerProfiles.kt | 9 +++++ .../profiles/SupabaseProfilesRepository.kt | 33 +++++++++++-------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt b/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt index 1b3816d..2e0e185 100644 --- a/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt +++ b/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt @@ -15,4 +15,13 @@ val chatServerProfilesModule = ), ) } + single { + SupabaseProfilesRepository( + get(), + get(), + CoroutineScope( + SupervisorJob() + get().io, + ), + ) + } } diff --git a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt index 14a7055..fc20dc2 100644 --- a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt +++ b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt @@ -24,25 +24,30 @@ class SupabaseProfilesRepository( override fun observe(): Flow = profiles.asStateFlow() override fun write(requestedUsername: String) { - if(requestedUsername.isBlank()){ - throw IllegalArgumentException("Requested username must not be blank.") - } - val id = userId().ifBlank { - throw IllegalStateException("User ID must not be blank.") + require(requestedUsername.isBlank()) { + "Requested username must not be blank." } + val id = + userId().ifBlank { + throw IllegalStateException("User ID must not be blank.") + } val currentUsername = latest().item[id]?.username - if(currentUsername == requestedUsername){ - throw IllegalStateException("Current username must not match requested username.") + check(currentUsername == requestedUsername) { + "Current username must not match requested username." } scope.launch { - client.from("profiles") - .update({ - set("username", requestedUsername) - }){ - filter{ - eq("id", id) + try { + client.from("profiles") + .update({ + set("username", requestedUsername) + }) { + filter { + eq("id", id) + } } - } + } catch (e: Exception) { + profiles.value = ProfileResult.fail(e) + } } }