diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt index ba8fd803b..3c45f8af1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.utils.SortedList import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -39,23 +40,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.yield -/** - * Lifecycle state of an [ObservableEventStore.project] flow. - * - * - [Loading] is the initial state before the seed query completes. - * The UI should show a spinner / skeleton here. - * - [Loaded] holds the current deduped, sorted list of slots. Every - * membership change publishes a fresh [Loaded] instance; in-place - * addressable updates do *not* publish (the slots' own flows do). - */ -sealed interface ProjectionState { - data object Loading : ProjectionState - - data class Loaded( - val items: List>, - ) : ProjectionState -} - /** * State machine that maintains a reactive view over an * [ObservableEventStore] for a fixed set of [filters]. Pure logic, no @@ -106,8 +90,8 @@ class EventStoreProjection( * least one of these sets. Identity-keyed — [Filter] is `@Stable` * but not a data class. */ - private val perFilter: Map>> = - filters.associateWith { sortedSetOf(slotComparator()) } + private val perFilter: Map>> = + filters.associateWith { SortedList(slotComparator()) } /** * Run the seed query against the store and populate the indexes. @@ -158,7 +142,7 @@ class EventStoreProjection( if (perFilter.size == 1) { return ProjectionState.Loaded(perFilter.values.first().map { it.flow }) } - val union = sortedSetOf(slotComparator()) + val union = SortedList(slotComparator()) for (set in perFilter.values) union.addAll(set) return ProjectionState.Loaded(union.map { it.flow }) } @@ -260,7 +244,7 @@ class EventStoreProjection( private fun admit( slot: Slot, f: Filter, - set: java.util.SortedSet>, + set: SortedList>, ): Boolean { set.add(slot) val cap = f.limit ?: return false diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/ProjectionState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/ProjectionState.kt new file mode 100644 index 000000000..8b831c816 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/ProjectionState.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.cache.projection + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * Lifecycle state of an [ObservableEventStore.project] flow. + * + * - [Loading] is the initial state before the seed query completes. + * The UI should show a spinner / skeleton here. + * - [Loaded] holds the current deduped, sorted list of slots. Every + * membership change publishes a fresh [Loaded] instance; in-place + * addressable updates do *not* publish (the slots' own flows do). + */ +sealed interface ProjectionState { + data object Loading : ProjectionState + + data class Loaded( + val items: List>, + ) : ProjectionState +} + +inline fun ProjectionState.filterItems(predicate: (MutableStateFlow) -> Boolean): ProjectionState = + when (this) { + is ProjectionState.Loading -> this + is ProjectionState.Loaded -> ProjectionState.Loaded(items.filter(predicate)) + } + +inline fun ProjectionState.mapItems(transform: (MutableStateFlow) -> MutableStateFlow): ProjectionState = + when (this) { + is ProjectionState.Loading -> ProjectionState.Loading + is ProjectionState.Loaded -> ProjectionState.Loaded(items.map(transform)) + } + +inline fun Flow>.filterItems(crossinline predicate: (MutableStateFlow) -> Boolean): Flow> = map { it.filterItems(predicate) } + +inline fun Flow>.mapItems(crossinline transform: (MutableStateFlow) -> MutableStateFlow): Flow> = map { it.mapItems(transform) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/SortedList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/SortedList.kt new file mode 100644 index 000000000..8b1399075 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/SortedList.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +/** + * KMP substitute for `java.util.TreeSet`. Backed by a sorted [ArrayList]; + * O(log n) add/remove/contains via binary search, O(1) [last]/[size], + * in-order iteration. + * + * The [cmp] must be total — distinct elements must not compare equal, + * otherwise duplicates collapse silently. Optimised for small-to-medium + * `n`; insert/remove in the middle is O(n) due to ArrayList shifting, + * which is faster in practice than a tree for the bounded sizes typical + * of per-filter caps. + */ +class SortedList( + private val cmp: Comparator, +) : Iterable { + private val list = ArrayList() + + val size: Int get() = list.size + + fun isEmpty(): Boolean = list.isEmpty() + + fun add(element: E): Boolean { + val idx = list.binarySearch(element, cmp) + if (idx >= 0) return false + list.add(-idx - 1, element) + return true + } + + fun remove(element: E): Boolean { + val idx = list.binarySearch(element, cmp) + if (idx < 0) return false + list.removeAt(idx) + return true + } + + fun contains(element: E): Boolean = list.binarySearch(element, cmp) >= 0 + + fun last(): E = list.last() + + fun addAll(other: Iterable) { + for (e in other) add(e) + } + + override fun iterator(): Iterator = list.iterator() +}