Coverage Summary for Class: PluginHelpersKt (com.georgeci.moneysurfer.data.sync.plugin)

Class Method, % Branch, % Line, % Instruction, %
PluginHelpersKt 100% (4/4) 41.7% (5/12) 100% (15/15) 95.6% (173/181)
PluginHelpersKt$applyResolution$1
Total 100% (4/4) 41.7% (5/12) 100% (15/15) 95.6% (173/181)


 package com.georgeci.moneysurfer.data.sync.plugin
 import co.touchlab.kermit.Logger
 import com.georgeci.moneysurfer.sync.plugin.EntityApplyResult
 import com.georgeci.moneysurfer.sync.plugin.RemoteDocument
 import com.georgeci.moneysurfer.sync.repository.ConflictResolution
 import kotlinx.serialization.DeserializationStrategy
 import kotlin.coroutines.cancellation.CancellationException
 
 private val decodeLog = Logger.withTag("SyncDecode")
 
 /** Result for a document a plugin intentionally did not apply (skipped, not a conflict). */
 internal val SKIPPED_APPLY_RESULT = EntityApplyResult(applied = false, wasConflict = false)
 
 /**
  * Decodes [deserializer] from this document, returning `null` instead of throwing when the
  * document is malformed — a field whose wire type does not match the DTO (e.g. `amount: "abc"`
  * where a `Long` is expected).
  *
  * Poison-document guard (issue #156). Entity docs are pulled and decoded on every co-member's
  * device. Any member could historically write a wrong-typed field because the Firestore rules
  * validated only membership, not field shape. Such a doc threw inside [RemoteDocument.decode]
  * mid-pull and aborted the whole batch BEFORE the cursor advanced past it, so every later
  * incremental pull re-fetched it and failed again — a workspace-wide sync outage that retried
  * forever. Returning `null` lets the caller skip the row (returning [SKIPPED_APPLY_RESULT], which
  * still advances the cursor), containing the blast radius to that one document. The rules now also
  * reject such writes at the source; this keeps already-poisoned docs and older clients — which the
  * rules cannot retroactively fix — from taking sync down.
  *
  * Only the in-memory decode is guarded: the document is already fetched, so a throw here is always
  * a malformed payload, never a transient I/O error (those still surface and abort so they retry).
  * [CancellationException] is rethrown so coroutine cancellation is never swallowed; fatal [Error]s
  * (OOM, linkage) propagate because we catch [Exception], not [Throwable].
  */
 internal fun <T> RemoteDocument.decodeOrNull(deserializer: DeserializationStrategy<T>): T? = try {
     decode(deserializer)
 } catch (cancelled: CancellationException) {
     throw cancelled
 } catch (@Suppress("TooGenericExceptionCaught") failure: Exception) {
     decodeLog.e(throwable = failure) {
         "skipping undecodable ${deserializer.descriptor.serialName} doc id=$id"
     }
     null
 }
 
 internal suspend inline fun <T : Any> applyResolution(
     resolution: ConflictResolution<T>,
     crossinline write: suspend (T) -> Unit,
 ): EntityApplyResult = when (resolution) {
     is ConflictResolution.TakeRemote -> {
         write(resolution.value)
         EntityApplyResult(applied = true, wasConflict = false)
     }
     is ConflictResolution.Merged -> {
         write(resolution.value)
         EntityApplyResult(applied = true, wasConflict = true)
     }
     is ConflictResolution.TakeLocal -> EntityApplyResult(applied = false, wasConflict = true)
     ConflictResolution.Skip -> EntityApplyResult(applied = false, wasConflict = true)
 }