diff --git a/CHANGELOG.md b/CHANGELOG.md index efd5275..4db42b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +## [5.0.0-beta01] (2023-05-19) + +* Delegate memory cache implementation and provide a hybrid cache with automatic list decomposition as a separate + artifact [#548](https://github.com/MobileNativeFoundation/Store/pull/548) + ## [5.0.0-alpha06] (2023-05-08) * Separate MutableStoreBuilder from @@ -255,7 +260,9 @@ This is a first alpha release of Store ported to RxJava 2. * The change log for Store version 1.x can be found [here](https://github.com/NYTimes/Store/blob/develop/CHANGELOG.md). -[Unreleased]: https://github.com/MobileNativeFoundation/Store/compare/v5.0.0-alpha06...HEAD +[Unreleased]: https://github.com/MobileNativeFoundation/Store/compare/v5.0.0-beta01...HEAD + +[5.0.0-beta01]: https://github.com/MobileNativeFoundation/Store/releases/tag/5.0.0-beta01 [5.0.0-alpha06]: https://github.com/MobileNativeFoundation/Store/releases/tag/5.0.0-alpha06 diff --git a/README.md b/README.md index e6c13bb..163bd9a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ #### Android ```kotlin -implementation "org.mobilenativefoundation.store:store5:5.0.0-alpha06" +implementation "org.mobilenativefoundation.store:store5:5.0.0-beta01" implementation "org.jetbrains.kotlinx:atomicfu:0.18.5" ``` @@ -46,7 +46,7 @@ implementation "org.jetbrains.kotlinx:atomicfu:0.18.5" ```kotlin commonMain { dependencies { - implementation("org.mobilenativefoundation.store:store5:5.0.0-alpha06") + implementation("org.mobilenativefoundation.store:store5:5.0.0-beta01") implementation("org.jetbrains.kotlinx:atomicfu:0.18.5") } } diff --git a/buildSrc/src/main/kotlin/Version.kt b/buildSrc/src/main/kotlin/Version.kt index 7afc1a6..8228028 100644 --- a/buildSrc/src/main/kotlin/Version.kt +++ b/buildSrc/src/main/kotlin/Version.kt @@ -32,6 +32,6 @@ object Version { const val navigationCompose = "2.5.2" const val sqlDelight = "1.5.4" const val sqlDelightGradlePlugin = sqlDelight - const val store = "5.0.0-alpha06" + const val store = "5.0.0-beta01" const val truth = "1.1.3" } \ No newline at end of file diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt new file mode 100644 index 0000000..d523bfb --- /dev/null +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt @@ -0,0 +1,5 @@ +package org.mobilenativefoundation.store.cache5 + +interface Identifiable { + val id: Id +} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt new file mode 100644 index 0000000..3dfc0b5 --- /dev/null +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt @@ -0,0 +1,77 @@ +@file:Suppress("UNCHECKED_CAST") + +package org.mobilenativefoundation.store.cache5 + +/** + * Implementation of a cache with collection decomposition. + * Stores and manages the relationship among single items and collections. + * Delegates cache storage and behavior to Guava caches. + */ +class MultiCache>( + cacheBuilder: CacheBuilder +) { + private val collectionCacheBuilder = CacheBuilder>().apply { + expireAfterAccess(cacheBuilder.expireAfterAccess) + expireAfterWrite(cacheBuilder.expireAfterWrite) + + if (cacheBuilder.maximumSize > 0) { + maximumSize(cacheBuilder.maximumSize) + } + // TODO(): Support weigher + } + + private val itemKeyToCollectionKey = mutableMapOf() + + private val itemCache: Cache = cacheBuilder.build() + + private val collectionCache: Cache> = collectionCacheBuilder.build() + + fun getItem(key: Key): Output? { + return itemCache.getIfPresent(key) + } + + fun putItem(key: Key, item: Output) { + itemCache.put(key, item) + + val collectionKey = itemKeyToCollectionKey[key] + if (collectionKey != null) { + val updatedCollection = collectionCache.getIfPresent(collectionKey)?.map { if (it.id == key) item else it } + if (updatedCollection != null) { + collectionCache.put(collectionKey, updatedCollection) + } + } + } + + fun > getCollection(key: Key): T? { + return collectionCache.getIfPresent(key) as? T + } + + fun putCollection(key: Key, items: Collection) { + collectionCache.put(key, items) + items.forEach { item -> + itemCache.put(item.id, item) + itemKeyToCollectionKey[item.id] = key + } + } + + fun invalidateItem(key: Key) { + itemCache.invalidate(key) + } + + fun invalidateCollection(key: Key) { + val collection = collectionCache.getIfPresent(key) + collection?.forEach { item -> + invalidateItem(item.id) + } + collectionCache.invalidate(key) + } + + fun invalidateAll() { + collectionCache.invalidateAll() + itemCache.invalidateAll() + } + + fun size(): Long { + return itemCache.size() + collectionCache.size() + } +} diff --git a/gradle.properties b/gradle.properties index ff0bf4b..51dac28 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ org.gradle.jvmargs=-XX:MaxMetaspaceSize=2G # POM file GROUP=org.mobilenativefoundation.store -VERSION_NAME=5.0.0-alpha06 +VERSION_NAME=5.0.0-beta01 POM_PACKAGING=pom POM_DESCRIPTION = Store5 is a Kotlin Multiplatform network-resilient repository layer diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt index e46cb52..e68fead 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt @@ -16,8 +16,10 @@ package org.mobilenativefoundation.store.store5 import kotlinx.coroutines.CoroutineScope +import org.mobilenativefoundation.store.cache5.Cache import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcher import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcherAndSourceOfTruth +import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcherSourceOfTruthAndMemoryCache /** * Main entry point for creating a [Store]. @@ -69,6 +71,17 @@ interface StoreBuilder { fun from( fetcher: Fetcher, sourceOfTruth: SourceOfTruth - ): StoreBuilder = storeBuilderFromFetcherAndSourceOfTruth(fetcher = fetcher, sourceOfTruth = sourceOfTruth) + ): StoreBuilder = + storeBuilderFromFetcherAndSourceOfTruth(fetcher = fetcher, sourceOfTruth = sourceOfTruth) + + fun from( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + memoryCache: Cache, + ): StoreBuilder = storeBuilderFromFetcherSourceOfTruthAndMemoryCache( + fetcher, + sourceOfTruth, + memoryCache + ) } } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt index f5b478d..a10c3af 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt @@ -23,7 +23,7 @@ package org.mobilenativefoundation.store.store5 * starting the stream from the local [com.dropbox.android.external.store4.impl.SourceOfTruth] and memory cache * */ -data class StoreReadRequest private constructor( +data class StoreReadRequest private constructor( val key: Key, private val skippedCaches: Int, val refresh: Boolean = false, diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt index a62c97f..2114882 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt @@ -2,6 +2,8 @@ package org.mobilenativefoundation.store.store5.impl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope +import org.mobilenativefoundation.store.cache5.Cache +import org.mobilenativefoundation.store.cache5.CacheBuilder import org.mobilenativefoundation.store.store5.Bookkeeper import org.mobilenativefoundation.store.store5.Converter import org.mobilenativefoundation.store.store5.Fetcher @@ -24,9 +26,16 @@ fun mutableStoreBuilderFro sourceOfTruth: SourceOfTruth, ): MutableStoreBuilder = RealMutableStoreBuilder(fetcher, sourceOfTruth) +fun mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + memoryCache: Cache +): MutableStoreBuilder = RealMutableStoreBuilder(fetcher, sourceOfTruth, memoryCache) + internal class RealMutableStoreBuilder( private val fetcher: Fetcher, private val sourceOfTruth: SourceOfTruth? = null, + private val memoryCache: Cache? = null ) : MutableStoreBuilder { private var scope: CoroutineScope? = null private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy @@ -62,9 +71,25 @@ internal class RealMutableStoreBuilder().apply { + if (cachePolicy!!.hasAccessPolicy) { + expireAfterAccess(cachePolicy!!.expireAfterAccess) + } + if (cachePolicy!!.hasWritePolicy) { + expireAfterWrite(cachePolicy!!.expireAfterWrite) + } + if (cachePolicy!!.hasMaxSize) { + maximumSize(cachePolicy!!.maxSize) + } + + if (cachePolicy!!.hasMaxWeight) { + weigher(cachePolicy!!.maxWeight) { key, value -> cachePolicy!!.weigher.weigh(key, value) } + } + }.build() + } ) override fun build( diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt index e932b5e..caab45e 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt @@ -25,12 +25,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.transform -import org.mobilenativefoundation.store.cache5.CacheBuilder +import org.mobilenativefoundation.store.cache5.Cache import org.mobilenativefoundation.store.store5.CacheType import org.mobilenativefoundation.store.store5.Converter import org.mobilenativefoundation.store.store5.ExperimentalStoreApi import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.MemoryPolicy import org.mobilenativefoundation.store.store5.SourceOfTruth import org.mobilenativefoundation.store.store5.Store import org.mobilenativefoundation.store.store5.StoreReadRequest @@ -47,7 +46,7 @@ internal class RealStore( sourceOfTruth: SourceOfTruth? = null, private val converter: Converter? = null, private val validator: Validator?, - private val memoryPolicy: MemoryPolicy? + private val memCache: Cache? ) : Store { /** * This source of truth is either a real database or an in memory source of truth created by @@ -61,24 +60,6 @@ internal class RealStore( SourceOfTruthWithBarrier(it, converter) } - private val memCache = memoryPolicy?.let { - CacheBuilder().apply { - if (memoryPolicy.hasAccessPolicy) { - expireAfterAccess(memoryPolicy.expireAfterAccess) - } - if (memoryPolicy.hasWritePolicy) { - expireAfterWrite(memoryPolicy.expireAfterWrite) - } - if (memoryPolicy.hasMaxSize) { - maximumSize(memoryPolicy.maxSize) - } - - if (memoryPolicy.hasMaxWeight) { - weigher(memoryPolicy.maxWeight) { key, value -> memoryPolicy.weigher.weigh(key, value) } - } - }.build() - } - /** * Fetcher controller maintains 1 and only 1 `Multicaster` for a given key to ensure network * requests are shared. diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt index 8e4f7e9..4fab057 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt @@ -4,6 +4,8 @@ package org.mobilenativefoundation.store.store5.impl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope +import org.mobilenativefoundation.store.cache5.Cache +import org.mobilenativefoundation.store.cache5.CacheBuilder import org.mobilenativefoundation.store.store5.Converter import org.mobilenativefoundation.store.store5.Fetcher import org.mobilenativefoundation.store.store5.MemoryPolicy @@ -24,9 +26,16 @@ fun storeBuilderFromFetcherAndSourceOfTru sourceOfTruth: SourceOfTruth, ): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth) +fun storeBuilderFromFetcherSourceOfTruthAndMemoryCache( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth, + memoryCache: Cache, +): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth, memoryCache) + internal class RealStoreBuilder( private val fetcher: Fetcher, - private val sourceOfTruth: SourceOfTruth? = null + private val sourceOfTruth: SourceOfTruth? = null, + private val memoryCache: Cache? = null ) : StoreBuilder { private var scope: CoroutineScope? = null private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy @@ -57,17 +66,42 @@ internal class RealStoreBuilder().apply { + if (cachePolicy!!.hasAccessPolicy) { + expireAfterAccess(cachePolicy!!.expireAfterAccess) + } + if (cachePolicy!!.hasWritePolicy) { + expireAfterWrite(cachePolicy!!.expireAfterWrite) + } + if (cachePolicy!!.hasMaxSize) { + maximumSize(cachePolicy!!.maxSize) + } + + if (cachePolicy!!.hasMaxWeight) { + weigher(cachePolicy!!.maxWeight) { key, value -> cachePolicy!!.weigher.weigh(key, value) } + } + }.build() + } ) override fun toMutableStoreBuilder(): MutableStoreBuilder { fetcher as Fetcher - return if (sourceOfTruth == null) { + return if (sourceOfTruth == null && memoryCache == null) { mutableStoreBuilderFromFetcher(fetcher) + } else if (memoryCache == null) { + mutableStoreBuilderFromFetcherAndSourceOfTruth( + fetcher, + sourceOfTruth as SourceOfTruth + ) } else { - mutableStoreBuilderFromFetcherAndSourceOfTruth(fetcher, sourceOfTruth as SourceOfTruth) + mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache( + fetcher, + sourceOfTruth as SourceOfTruth, + memoryCache + ) }.apply { if (this@RealStoreBuilder.scope != null) { scope(this@RealStoreBuilder.scope!!) diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MutableStoreWithMultiCacheTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MutableStoreWithMultiCacheTests.kt new file mode 100644 index 0000000..c053098 --- /dev/null +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MutableStoreWithMultiCacheTests.kt @@ -0,0 +1,86 @@ +package org.mobilenativefoundation.store.store5 + +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store.cache5.CacheBuilder +import org.mobilenativefoundation.store.cache5.MultiCache +import org.mobilenativefoundation.store.store5.util.fake.NoteCollections +import org.mobilenativefoundation.store.store5.util.fake.Notes +import org.mobilenativefoundation.store.store5.util.fake.NotesApi +import org.mobilenativefoundation.store.store5.util.fake.NotesDatabase +import org.mobilenativefoundation.store.store5.util.fake.NotesMemoryCache +import org.mobilenativefoundation.store.store5.util.fake.NotesKey +import org.mobilenativefoundation.store.store5.util.model.NetworkNote +import org.mobilenativefoundation.store.store5.util.model.Note +import org.mobilenativefoundation.store.store5.util.model.NoteData +import org.mobilenativefoundation.store.store5.util.model.SOTNote +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class MutableStoreWithMultiCacheTests { + private val testScope = TestScope() + private lateinit var api: NotesApi + private lateinit var database: NotesDatabase + + @BeforeTest + fun before() { + api = NotesApi() + database = NotesDatabase() + } + + @Test + fun givenEmptyStoreWhenListFromFetcherThenListIsDecomposed() = testScope.runTest { + val memoryCache = NotesMemoryCache(MultiCache(CacheBuilder())) + + val store = StoreBuilder.from( + fetcher = Fetcher.of { key -> api.get(key) }, + sourceOfTruth = SourceOfTruth.of( + nonFlowReader = { key -> database.get(key) }, + writer = { key, note -> database.put(key, note) }, + delete = null, + deleteAll = null + ), + memoryCache = memoryCache + ).toMutableStoreBuilder() + .converter( + Converter.Builder() + .fromLocalToOutput { local -> local.data!! } + .fromNetworkToOutput { network -> network.data!! } + .fromOutputToLocal { output -> SOTNote(output, Long.MAX_VALUE) } + .build() + ).build( + updater = Updater.by( + post = { _, _ -> UpdaterResult.Error.Exception(Exception()) } + ) + ) + + val freshRequest = StoreReadRequest.fresh(NotesKey.Collection(NoteCollections.Keys.OneAndTwo)) + + val freshStream = store.stream(freshRequest) + + val actualResultFromFreshStream = freshStream.take(2).toList() + val expectedResultFromFreshStream = listOf( + StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), + StoreReadResponse.Data(NoteCollections.OneAndTwo, StoreReadResponseOrigin.Fetcher()) + ) + + assertEquals(expectedResultFromFreshStream, actualResultFromFreshStream) + + val singleFromMemoryCache = memoryCache.getIfPresent(NotesKey.Single(Notes.One.id)) + assertIs(singleFromMemoryCache) + assertEquals(singleFromMemoryCache.item, Notes.One) + + val cachedRequest = StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = true) + val cachedStream = store.stream(cachedRequest) + val actualResultFromCachedStream = cachedStream.take(1).toList() + val expectedResultFromCachedStream = listOf( + StoreReadResponse.Data(NoteData.Single(Notes.One), StoreReadResponseOrigin.Cache) + ) + + assertEquals(expectedResultFromCachedStream, actualResultFromCachedStream) + } +} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt index 53cec64..8263d61 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt @@ -14,6 +14,7 @@ import org.mobilenativefoundation.store.store5.util.fake.NotesApi import org.mobilenativefoundation.store.store5.util.fake.NotesBookkeeping import org.mobilenativefoundation.store.store5.util.fake.NotesConverterProvider import org.mobilenativefoundation.store.store5.util.fake.NotesDatabase +import org.mobilenativefoundation.store.store5.util.fake.NotesKey import org.mobilenativefoundation.store.store5.util.fake.NotesUpdaterProvider import org.mobilenativefoundation.store.store5.util.fake.NotesValidator import org.mobilenativefoundation.store.store5.util.model.CommonNote @@ -55,7 +56,7 @@ class UpdaterTests { clearAll = bookkeeping::clear ) - val store = MutableStoreBuilder.from( + val store = MutableStoreBuilder.from( fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) }, sourceOfTruth = SourceOfTruth.of( nonFlowReader = { key -> notes.get(key) }, @@ -71,7 +72,7 @@ class UpdaterTests { bookkeeper = bookkeeper ) - val readRequest = StoreReadRequest.fresh(Notes.One.id) + val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id)) val stream = store.stream(readRequest) @@ -80,27 +81,40 @@ class UpdaterTests { stream, listOf( StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data(CommonNote(NoteData.Single(Notes.One), ttl = ttl), StoreReadResponseOrigin.Fetcher()) + StoreReadResponse.Data( + CommonNote(NoteData.Single(Notes.One), ttl = ttl), + StoreReadResponseOrigin.Fetcher + () + ) ) ) val newNote = Notes.One.copy(title = "New Title-1") - val writeRequest = StoreWriteRequest.of( - key = Notes.One.id, + val writeRequest = StoreWriteRequest.of( + key = NotesKey.Single(Notes.One.id), value = CommonNote(NoteData.Single(newNote)) ) val storeWriteResponse = store.write(writeRequest) // Write is success - assertEquals(StoreWriteResponse.Success.Typed(NotesWriteResponse(Notes.One.id, true)), storeWriteResponse) + assertEquals( + StoreWriteResponse.Success.Typed(NotesWriteResponse(NotesKey.Single(Notes.One.id), true)), + storeWriteResponse + ) - val cachedReadRequest = StoreReadRequest.cached(Notes.One.id, refresh = false) + val cachedReadRequest = StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false) val cachedStream = store.stream(cachedReadRequest) // Cache + SOT are updated val firstResponse = cachedStream.first() - assertEquals(StoreReadResponse.Data(CommonNote(NoteData.Single(newNote), ttl = null), StoreReadResponseOrigin.Cache), firstResponse) + assertEquals( + StoreReadResponse.Data( + CommonNote(NoteData.Single(newNote), ttl = null), + StoreReadResponseOrigin.Cache + ), + firstResponse + ) val secondResponse = cachedStream.take(2).last() assertIs>(secondResponse) @@ -112,8 +126,11 @@ class UpdaterTests { assertNotNull(secondResponse.value.ttl) // API is updated - assertEquals(StoreWriteResponse.Success.Typed(NotesWriteResponse(Notes.One.id, true)), storeWriteResponse) - assertEquals(NetworkNote(NoteData.Single(newNote), ttl = null), api.db[Notes.One.id]) + assertEquals( + StoreWriteResponse.Success.Typed(NotesWriteResponse(NotesKey.Single(Notes.One.id), true)), + storeWriteResponse + ) + assertEquals(NetworkNote(NoteData.Single(newNote), ttl = null), api.db[NotesKey.Single(Notes.One.id)]) } @Test @@ -130,7 +147,7 @@ class UpdaterTests { clearAll = bookkeeping::clear ) - val store = MutableStoreBuilder.from( + val store = MutableStoreBuilder.from( fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) }, sourceOfTruth = SourceOfTruth.of( nonFlowReader = { key -> notes.get(key) }, @@ -146,7 +163,7 @@ class UpdaterTests { bookkeeper = bookkeeper ) - val readRequest = StoreReadRequest.fresh(Notes.One.id) + val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id)) val stream = store.stream(readRequest) @@ -155,11 +172,15 @@ class UpdaterTests { stream, listOf( StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data(CommonNote(NoteData.Single(Notes.One), ttl = ttl), StoreReadResponseOrigin.Fetcher()) + StoreReadResponse.Data( + CommonNote(NoteData.Single(Notes.One), ttl = ttl), + StoreReadResponseOrigin.Fetcher + () + ) ) ) - val cachedReadRequest = StoreReadRequest.cached(Notes.One.id, refresh = false) + val cachedReadRequest = StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false) val cachedStream = store.stream(cachedReadRequest) // Cache + SOT are updated @@ -170,7 +191,11 @@ class UpdaterTests { assertEmitsExactly( cachedStream, listOf( - StoreReadResponse.Data(CommonNote(NoteData.Single(Notes.One), ttl = ttl), StoreReadResponseOrigin.Fetcher()) + StoreReadResponse.Data( + CommonNote(NoteData.Single(Notes.One), ttl = ttl), + StoreReadResponseOrigin.Fetcher + () + ) ) ) } @@ -187,7 +212,7 @@ class UpdaterTests { clearAll = bookkeeping::clear ) - val store = MutableStoreBuilder.from( + val store = MutableStoreBuilder.from( fetcher = Fetcher.ofFlow { key -> val network = api.get(key) flow { emit(network) } @@ -207,13 +232,16 @@ class UpdaterTests { ) val newNote = Notes.One.copy(title = "New Title-1") - val writeRequest = StoreWriteRequest.of( - key = Notes.One.id, + val writeRequest = StoreWriteRequest.of( + key = NotesKey.Single(Notes.One.id), value = CommonNote(NoteData.Single(newNote)) ) val storeWriteResponse = store.write(writeRequest) - assertEquals(StoreWriteResponse.Success.Typed(NotesWriteResponse(Notes.One.id, true)), storeWriteResponse) - assertEquals(NetworkNote(NoteData.Single(newNote)), api.db[Notes.One.id]) + assertEquals( + StoreWriteResponse.Success.Typed(NotesWriteResponse(NotesKey.Single(Notes.One.id), true)), + storeWriteResponse + ) + assertEquals(NetworkNote(NoteData.Single(newNote)), api.db[NotesKey.Single(Notes.One.id)]) } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt new file mode 100644 index 0000000..fbbb029 --- /dev/null +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.store5.util.fake + +import org.mobilenativefoundation.store.store5.util.model.NoteData + +internal object NoteCollections { + object Keys { + const val OneAndTwo = "ONE_AND_TWO" + } + val OneAndTwo = NoteData.Collection(listOf(Notes.One, Notes.Two)) +} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt index 1ee2b2c..8ea7713 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt @@ -6,14 +6,14 @@ import org.mobilenativefoundation.store.store5.util.model.NetworkNote import org.mobilenativefoundation.store.store5.util.model.NoteData import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse -internal class NotesApi : TestApi { - internal val db = mutableMapOf() +internal class NotesApi : TestApi { + internal val db = mutableMapOf() init { seed() } - override fun get(key: String, fail: Boolean, ttl: Long?): NetworkNote { + override fun get(key: NotesKey, fail: Boolean, ttl: Long?): NetworkNote { if (fail) { throw Exception() } @@ -26,7 +26,7 @@ internal class NotesApi : TestApi = mutableMapOf() - fun setLastFailedSync(key: String, timestamp: Long, fail: Boolean = false): Boolean { + private val log: MutableMap = mutableMapOf() + fun setLastFailedSync(key: NotesKey, timestamp: Long, fail: Boolean = false): Boolean { if (fail) { throw Exception() } @@ -10,7 +10,7 @@ class NotesBookkeeping { return true } - fun getLastFailedSync(key: String, fail: Boolean = false): Long? { + fun getLastFailedSync(key: NotesKey, fail: Boolean = false): Long? { if (fail) { throw Exception() } @@ -18,7 +18,7 @@ class NotesBookkeeping { return log[key] } - fun clear(key: String, fail: Boolean = false): Boolean { + fun clear(key: NotesKey, fail: Boolean = false): Boolean { if (fail) { throw Exception() } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt index 0df4f08..679f2f1 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt @@ -3,8 +3,8 @@ package org.mobilenativefoundation.store.store5.util.fake import org.mobilenativefoundation.store.store5.util.model.SOTNote internal class NotesDatabase { - private val db: MutableMap = mutableMapOf() - fun put(key: String, input: SOTNote, fail: Boolean = false): Boolean { + private val db: MutableMap = mutableMapOf() + fun put(key: NotesKey, input: SOTNote, fail: Boolean = false): Boolean { if (fail) { throw Exception() } @@ -13,7 +13,7 @@ internal class NotesDatabase { return true } - fun get(key: String, fail: Boolean = false): SOTNote? { + fun get(key: NotesKey, fail: Boolean = false): SOTNote? { if (fail) { throw Exception() } @@ -21,7 +21,7 @@ internal class NotesDatabase { return db[key] } - fun clear(key: String, fail: Boolean = false): Boolean { + fun clear(key: NotesKey, fail: Boolean = false): Boolean { if (fail) { throw Exception() } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt new file mode 100644 index 0000000..40f3242 --- /dev/null +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store.store5.util.fake + +sealed class NotesKey { + data class Single(val id: String) : NotesKey() + data class Collection(val id: String) : NotesKey() +} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesMemoryCache.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesMemoryCache.kt new file mode 100644 index 0000000..98bf49c --- /dev/null +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesMemoryCache.kt @@ -0,0 +1,89 @@ +package org.mobilenativefoundation.store.store5.util.fake + +import org.mobilenativefoundation.store.cache5.Cache +import org.mobilenativefoundation.store.cache5.MultiCache +import org.mobilenativefoundation.store.store5.util.model.Note +import org.mobilenativefoundation.store.store5.util.model.NoteData + +internal class NotesMemoryCache(private val delegate: MultiCache) : Cache { + override fun getIfPresent(key: NotesKey): NoteData? = when (key) { + is NotesKey.Collection -> { + val items = delegate.getCollection>(key.id) + if (items != null) { + NoteData.Collection(items) + } else { + null + } + } + + is NotesKey.Single -> { + val item = delegate.getItem(key.id) + if (item != null) { + NoteData.Single(item) + } else { + null + } + } + } + + override fun getOrPut(key: NotesKey, valueProducer: () -> NoteData): NoteData { + val collection = getIfPresent(key) + return if (collection == null) { + val noteData = valueProducer() + put(key, noteData) + noteData + } else { + collection + } + } + + override fun getAllPresent(keys: List<*>): Map { + val map = mutableMapOf() + + keys.filterIsInstance().forEach { key -> + val noteData = getIfPresent(key) + if (noteData != null) { + map[key] = noteData + } + } + + return map + } + + override fun invalidateAll() { + delegate.invalidateAll() + } + + override fun size(): Long { + return delegate.size() + } + + override fun invalidateAll(keys: List) { + keys.forEach { key -> + invalidate(key) + } + } + + override fun invalidate(key: NotesKey) = when (key) { + is NotesKey.Collection -> delegate.invalidateCollection(key.id) + is NotesKey.Single -> delegate.invalidateItem(key.id) + } + + override fun putAll(map: Map) { + map.entries.forEach { (key, noteData) -> + put(key, noteData) + } + } + + override fun put(key: NotesKey, value: NoteData) = when (key) { + is NotesKey.Collection -> { + require(value is NoteData.Collection) + delegate.putCollection(key.id, value.items) + } + + is NotesKey.Single -> { + require(value is NoteData.Single) + delegate.putItem(key.id, value.item) + } + } +} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt index f6624ef..98e9d89 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt @@ -6,7 +6,7 @@ import org.mobilenativefoundation.store.store5.util.model.CommonNote import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse internal class NotesUpdaterProvider(private val api: NotesApi) { - fun provide(): Updater = Updater.by( + fun provide(): Updater = Updater.by( post = { key, input -> val response = api.post(key, input) if (response.ok) { diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt index 65ca826..5c6792e 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt @@ -1,12 +1,15 @@ package org.mobilenativefoundation.store.store5.util.model +import org.mobilenativefoundation.store.cache5.Identifiable +import org.mobilenativefoundation.store.store5.util.fake.NotesKey + internal sealed class NoteData { data class Single(val item: Note) : NoteData() data class Collection(val items: List) : NoteData() } internal data class NotesWriteResponse( - val key: String, + val key: NotesKey, val ok: Boolean ) @@ -26,7 +29,7 @@ internal data class SOTNote( ) internal data class Note( - val id: String, + override val id: String, val title: String, val content: String -) +) : Identifiable