Skip to content

Commit

Permalink
strip out MNF/store, just use sqkon directly
Browse files Browse the repository at this point in the history
  • Loading branch information
JvmName committed Jan 9, 2025
1 parent 37e4263 commit cd09088
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 226 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import kotlinx.datetime.Instant
import logcat.logcat
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.Provides
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn
import java.io.File
import kotlin.reflect.KClass

@ContributesTo(AppScope::class)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.jvmname.acquisitive.network.model

import android.content.ClipData.Item
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import com.squareup.moshi.JsonClass
Expand All @@ -8,6 +9,7 @@ import dev.jvmname.acquisitive.util.ItemIdArray
import dev.zacsweers.moshix.sealed.annotations.TypeLabel
import kotlinx.datetime.Instant
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable

@Immutable
sealed interface ShadedHnItem {
Expand All @@ -18,6 +20,7 @@ sealed interface ShadedHnItem {
value class Full(val item: HnItem) : ShadedHnItem
}


@[JvmInline Parcelize Immutable JsonClass(generateAdapter = false)]
value class ItemId(val id: Int) : Parcelable

Expand Down Expand Up @@ -192,3 +195,9 @@ fun HnItem.copy(

fun ItemId.shaded() = ShadedHnItem.Shallow(this)
fun HnItem.shaded() = ShadedHnItem.Full(this)

val ShadedHnItem.id : ItemId
get() = when(this){
is ShadedHnItem.Full -> item.id
is ShadedHnItem.Shallow -> item
}
286 changes: 83 additions & 203 deletions app/src/main/java/dev/jvmname/acquisitive/repo/HnItemStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,249 +2,129 @@

package dev.jvmname.acquisitive.repo

import androidx.compose.runtime.Immutable
import android.util.SparseArray
import com.mercury.sqkon.db.KeyValueStorage
import com.mercury.sqkon.db.OrderBy
import com.mercury.sqkon.db.OrderDirection
import com.mercury.sqkon.db.Sqkon
import com.mercury.sqkon.db.eq
import dev.drewhamilton.poko.Poko
import dev.jvmname.acquisitive.di.AppCrScope
import dev.jvmname.acquisitive.network.HnClient
import dev.jvmname.acquisitive.network.model.FetchMode
import dev.jvmname.acquisitive.network.model.HnItem
import dev.jvmname.acquisitive.network.model.ItemId
import dev.jvmname.acquisitive.network.model.ShadedHnItem
import dev.jvmname.acquisitive.network.model.id
import dev.jvmname.acquisitive.network.model.shaded
import dev.jvmname.acquisitive.util.ItemIdArray
import dev.jvmname.acquisitive.util.fetchAsync
import dev.jvmname.acquisitive.util.retry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import logcat.asLog
import logcat.logcat
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import me.tatarka.inject.annotations.Inject
import org.mobilenativefoundation.store.store5.Converter
import org.mobilenativefoundation.store.store5.Fetcher
import org.mobilenativefoundation.store.store5.FetcherResult
import org.mobilenativefoundation.store.store5.SourceOfTruth
import org.mobilenativefoundation.store.store5.StoreBuilder
import org.mobilenativefoundation.store.store5.StoreReadRequest
import org.mobilenativefoundation.store.store5.StoreReadResponse


@Immutable
sealed interface StoryItemKey {
@[JvmInline Immutable]
value class Single(val id: ItemId) : StoryItemKey

@[Poko Immutable]
class All(val fetchMode: FetchMode, val window: Int) : StoryItemKey
}

sealed interface StoryItemResult<T> {
@[JvmInline Immutable]
value class Single<T>(val item: T) : StoryItemResult<T>

@[Poko Immutable]
class All<T>(val items: List<T>, val fetchMode: FetchMode) : StoryItemResult<T>
}

private sealed interface NetworkResult<T> {
@[JvmInline Immutable]
value class Single<T>(val item: T) : NetworkResult<T>

@[Poko Immutable]
class All<T>(
val full: List<T>,
val shallow: ItemIdArray,
val fetchMode: FetchMode,
) : NetworkResult<T>
}


private typealias NetworkItem = NetworkResult<HnItem>
private typealias OutputItem = StoryItemResult<ShadedHnItem>

@[Serializable JvmInline]
private value class ShadedItemList(val list: List<ShadedHnItem>)

@Inject
class HnItemStore(
skn: Sqkon,
private val client: HnClient,
@AppCrScope scope: CoroutineScope,
@AppCrScope private val scope: CoroutineScope,
) {
private val storage = skn.keyValueStorage<HnItemEntity>(
name = HnItemEntity::class.simpleName!!,
private val storage = skn.keyValueStorage<ShadedItemList>(
name = ShadedItemList::class.simpleName!!,
config = KeyValueStorage.Config(dispatcher = Dispatchers.IO)
)
private val store = StoreBuilder
.from(
fetcher = buildFetcher(),
sourceOfTruth = buildSoT(),
converter = buildConverter(),
)
.scope(scope)
// .cachePolicy(
// MemoryPolicy.MemoryPolicyBuilder<StoryItemKey, OutputItem>()
// .setMaxSize(20_971_520L) // 20 MB
// .setExpireAfterWrite(30.minutes)
// .build()
// )
.build()

suspend fun get(key: StoryItemKey): OutputItem {
return store.stream(StoreReadRequest.cached(key, refresh = true))
.filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData }
.first()
.requireData()
}

fun stream(key: StoryItemKey): Flow<OutputItem> {
return store.stream(StoreReadRequest.cached(key, refresh = true))
.mapNotNull { it.dataOrNull() }
}

/*
* below is the setup methods for the store
*/

private fun buildFetcher() = Fetcher.of<StoryItemKey, NetworkItem> { key ->
when (key) {
is StoryItemKey.Single -> {
NetworkResult.Single(retry(3) { client.getItem(key.id) })
}

is StoryItemKey.All -> {
val itemsIds = try {
logcat { "***fetching items for $key" }
client.getStories(key.fetchMode)
} catch (e: Exception) {
logcat { "***fetcher internal error: " + e.asLog() }
throw e
}

val full = itemsIds
.take(key.window)
.fetchAsync {
retry(2) {
client.getItem(it) //todo if this fails return the old ID so I don't lose it
}
suspend fun getItem(mode: FetchMode, itemId: ItemId): ShadedHnItem.Full {
val storedList = coroutineScope {
storage.selectByKey(mode.value)
.firstOrNull()
?.list.orEmpty()
}
val storedIndex = storedList.indexOfFirst { it.id == itemId }
val shouldCache = storedIndex >= 0
val networkItem = withContext(Dispatchers.IO) { client.getItem(itemId) }

return networkItem.shaded().also {
if (shouldCache) {
storedList
.toMutableList()
.apply {
set(storedIndex, ShadedHnItem.Full(networkItem))
}
val shallow = ItemIdArray(itemsIds.size - key.window) { i ->
itemsIds[key.window + i]
scope.launch(Dispatchers.IO) {
storage.update(mode.value, ShadedItemList(storedList))
}
NetworkResult.All(
full = full,
shallow = shallow,
fetchMode = key.fetchMode

)
}
}
}

private fun buildSoT(): SourceOfTruth<StoryItemKey, List<HnItemEntity>, OutputItem> {
return SourceOfTruth.of(
reader = { key ->
when (key) {
is StoryItemKey.Single -> try {
storage.selectByKey(key.toStringId())
.map { entity ->
entity?.let { StoryItemResult.Single(it.toShadedItem()) }
}
} catch (e: Exception) {
logcat { "***skn error: " + e.asLog() }
throw e
}
fun stream(mode: FetchMode, window: Int): Flow<List<ShadedHnItem>> {
return storage.selectByKey(mode.value)
.map { it?.list.orEmpty() }
.onStart { emit(getNetworkItems(mode, window)) }
}

is StoryItemKey.All -> try {
storage.select(
where = HnItemEntity::fetchMode eq key.fetchMode.name,
orderBy = listOf(OrderBy(HnItemEntity::index, OrderDirection.ASC))
)
.map { list ->
StoryItemResult.All(
list.map(HnItemEntity::toShadedItem),
key.fetchMode
)
}
} catch (e: Exception) {
logcat { "***skn error: " + e.asLog() }
throw e
}
}
},
writer = { key, item ->
when (key) {
is StoryItemKey.Single -> {
try {
storage.upsert(key.toStringId(), item.first())
} catch (e: Exception) {
logcat { "***skn error: " + e.asLog() }
throw e
}
}

is StoryItemKey.All -> try {
storage.upsertAll(item.associateBy { it.id.toString() })
} catch (e: Exception) {
logcat { "***skn error: " + e.asLog() }
throw e
}
}
},
delete = { key ->
when (key) {
is StoryItemKey.Single -> storage.deleteByKey(key.toStringId())
is StoryItemKey.All -> storage.delete(HnItemEntity::fetchMode eq key.fetchMode.name)
}
},
deleteAll = storage::deleteAll,
)
}
private suspend fun getNetworkItems(mode: FetchMode, window: Int): List<ShadedHnItem> {
val fullThenShallows = fetchNetworkItems(mode, window)

private fun buildConverter() = Converter.Builder<NetworkItem, List<HnItemEntity>, OutputItem>()
.fromNetworkToLocal { networkItem ->
when (networkItem) {
is NetworkResult.Single -> listOfNotNull(networkItem.item.toEntity())
is NetworkResult.All -> {
val full = networkItem.full
val shallow = networkItem.shallow
buildList(full.size + shallow.size) {
full.forEachIndexed { it, item ->
add(item.toEntity(networkItem.fetchMode, index = it))
}
shallow.forEachIndexed { it, item ->
add(item.toEntity(networkItem.fetchMode, index = it + full.size))
}
}
}
val storedList = coroutineScope {
storage.selectByKey(mode.value)
.map { it?.list }
.firstOrNull()
.orEmpty()
}
if (storedList.isEmpty() || fullThenShallows.size > storedList.size) {
scope.launch(Dispatchers.IO) {
storage.update(mode.value, ShadedItemList(fullThenShallows))
}
return fullThenShallows
}
.fromOutputToLocal { outputItem ->
when (outputItem) {
is StoryItemResult.Single -> listOf(outputItem.item.toEntity(index = -1))
is StoryItemResult.All -> outputItem.items.mapIndexed { i, item ->
item.toEntity(outputItem.fetchMode, i)
val mapping = buildMapping(storedList)

//copy any Full items into the FullThenShallows list
val fullyUpdated = fullThenShallows.map { item ->
if (item is ShadedHnItem.Shallow) {
when (val fromMapping = mapping[item.id.id]) {
null, is ShadedHnItem.Shallow -> item
else -> fromMapping
}
} else {
item
}
}
.build()
}
scope.launch(Dispatchers.IO) {
storage.update(mode.value, ShadedItemList(fullyUpdated))
}
return fullyUpdated
}

private suspend fun fetchNetworkItems(mode: FetchMode, window: Int): List<ShadedHnItem> =
withContext(Dispatchers.IO) {
val ids = client.getStories(mode)
val fulls = ids.take(window).fetchAsync { retry(3) { client.getItem(it) } }
val shallows = ItemIdArray(ids.size - window) { i -> ids[window + i] }

private fun <K : Any, R : Any> Fetcher.Companion.fetcherResult(fetch: suspend (K) -> R): Fetcher<K, R> {
return ofResult { k ->
try {
FetcherResult.Data(fetch(k))
} catch (e: Exception) {
logcat { "***fetcher error: " + e.asLog() }
FetcherResult.Error.Exception(e)
//equivalent to fulls.map(::Full) + shallows.map(::Shallow), but with fewer allocations
buildList(ids.size) {
fulls.forEach { this.add(ShadedHnItem.Full(it)) }
shallows.forEach { this.add(ShadedHnItem.Shallow(it)) }
}
}

private fun buildMapping(storedList: List<ShadedHnItem>): SparseArray<ShadedHnItem> {
val map = SparseArray<ShadedHnItem>(storedList.size)
storedList.forEach { item ->
map.append(item.id.id, item)
}
return map
}
}

private inline fun StoryItemKey.Single.toStringId() = id.id.toString()
}
Loading

0 comments on commit cd09088

Please sign in to comment.