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

Class Method, % Branch, % Line, % Instruction, %
TombstonePushKt 100% (2/2) 85.7% (6/7) 92.3% (12/13) 94.2% (162/172)
TombstonePushKt$pushToCollection$1
TombstonePushKt$WhenMappings
Total 100% (2/2) 85.7% (6/7) 92.3% (12/13) 94.2% (162/172)


 package com.georgeci.moneysurfer.data.sync.plugin
 
 import com.georgeci.moneysurfer.data.sync.WorkspaceDocRef
 import com.georgeci.moneysurfer.data.sync.WorkspaceDocumentWriter
 import com.georgeci.moneysurfer.sync.repository.MutationOperation
 import com.georgeci.moneysurfer.sync.repository.PendingMutation
 import kotlinx.serialization.Serializable
 import kotlinx.serialization.SerializationStrategy
 
 /**
  * Wire-format patch pushed for [com.georgeci.moneysurfer.sync.repository.MutationOperation.DELETE].
  *
  * Firestore rules deny hard deletes on every entity collection
  * (`allow delete: if false`, firestore.rules v2.0.0), so a local delete
  * replicates as a soft-delete `update`. Field names must match the entity DTOs
  * in RemoteDtos.kt: peers decode `deletedAt != null` and hard-delete the local
  * row, and their `updatedAt > cursor` pull picks the tombstone up
  * (see sync-pull-lww.md → Tombstones).
  */
 @Serializable
 data class TombstonePatch(
     val deletedAt: Long,
     val updatedAt: Long,
     val clientVersionCode: Int,
 )
 
 /**
  * [TombstonePatch.deletedAt] and [TombstonePatch.updatedAt] both take the
  * mutation's enqueue time, so a retried push writes an identical patch —
  * matching the idempotent re-push contract the rest of the outbox relies on.
  */
 internal fun tombstonePatchFor(
     mutation: PendingMutation,
     clientVersionCode: Int,
 ): TombstonePatch {
     val deletedAtMillis = mutation.createdAt.toEpochMilliseconds()
     return TombstonePatch(
         deletedAt = deletedAtMillis,
         updatedAt = deletedAtMillis,
         clientVersionCode = clientVersionCode,
     )
 }
 
 /**
  * The push half every workspace sub-collection plugin shares: an upsert writes whatever [doc]
  * resolves to now, and a delete writes a tombstone patch instead — Firestore rules deny hard
  * deletes.
  *
  * [doc] returning null means the row is no longer there to push. That is the same "created and
  * deleted between two drains" case the tombstone skip covers from the other side, and it must
  * return normally: `UploadPendingChangesUseCaseImpl` treats a push that throws as undelivered and
  * requeues it, which for a row that will never come back is a queue entry that never drains.
  */
 internal suspend fun <T : Any> WorkspaceDocumentWriter.pushToCollection(
     mutation: PendingMutation,
     collectionName: String,
     clientVersionCode: Int,
     strategy: SerializationStrategy<T>,
     doc: suspend (WorkspaceDocRef) -> T?,
 ) {
     val ref = WorkspaceDocRef.inCollection(
         workspaceId = requireNotNull(mutation.scopeKey) {
             "$collectionName mutation ${mutation.entityId} has no workspace scope"
         },
         collectionName = collectionName,
         documentId = mutation.entityId,
     )
     when (mutation.operation) {
         MutationOperation.INSERT,
         MutationOperation.UPDATE,
         -> doc(ref)?.let { set(ref, strategy, it) }
         MutationOperation.DELETE ->
             tombstone(ref, tombstonePatchFor(mutation, clientVersionCode))
     }
 }