feat(sync): implement Delta-Sync API and update clients to support offline-first workflow

Added `/ping/sync` endpoint with timestamp-based Delta-Sync functionality to efficiently support offline-first clients. Extended `PingApi` and frontend clients (`PingApiClient`, `PingApiKoinClient`) with `syncPings`. Updated repository, service, and controller logic for sync handling, including new JPA query `findByCreatedAtAfter`. Adjusted test doubles and completed unit tests for backend and frontend alignment. Documented sync approach and API usage.
This commit is contained in:
2026-01-17 12:22:16 +01:00
parent 59568a42d8
commit 351fe7a672
15 changed files with 190 additions and 62 deletions
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import java.time.Instant
/**
* Application Service.
@@ -43,4 +44,10 @@ class PingService(
override fun getPing(id: Uuid): Ping? {
return repository.findById(id)
}
@Transactional(readOnly = true)
override fun getPingsSince(timestamp: Long): List<Ping> {
val instant = Instant.ofEpochMilli(timestamp)
return repository.findByTimestampAfter(instant)
}
}
@@ -13,4 +13,5 @@ interface PingUseCase {
fun executePing(message: String): Ping
fun getPingHistory(): List<Ping>
fun getPing(id: Uuid): Ping?
fun getPingsSince(timestamp: Long): List<Ping>
}
@@ -2,6 +2,7 @@ package at.mocode.ping.domain
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import java.time.Instant
/**
* Secondary Port (Outbound Port).
@@ -12,4 +13,5 @@ interface PingRepository {
fun save(ping: Ping): Ping
fun findAll(): List<Ping>
fun findById(id: Uuid): Ping?
fun findByTimestampAfter(timestamp: Instant): List<Ping>
}
@@ -8,6 +8,7 @@ import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
import java.time.Instant
@OptIn(ExperimentalUuidApi::class)
@Repository
@@ -30,6 +31,10 @@ class PingRepositoryAdapter(
return jpaRepository.findById(id.toJavaUuid()).map { it.toDomain() }.orElse(null)
}
override fun findByTimestampAfter(timestamp: Instant): List<Ping> {
return jpaRepository.findByCreatedAtAfter(timestamp).map { it.toDomain() }
}
private fun Ping.toEntity() = PingJpaEntity(
id = this.id.toJavaUuid(),
message = this.message,
@@ -2,5 +2,8 @@ package at.mocode.ping.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
import java.time.Instant
interface SpringDataPingRepository : JpaRepository<PingJpaEntity, UUID>
interface SpringDataPingRepository : JpaRepository<PingJpaEntity, UUID> {
fun findByCreatedAtAfter(createdAt: Instant): List<PingJpaEntity>
}
@@ -3,6 +3,7 @@ package at.mocode.ping.infrastructure.web
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingEvent
import at.mocode.ping.api.PingResponse
import at.mocode.ping.application.PingUseCase
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
@@ -12,6 +13,7 @@ import org.springframework.web.bind.annotation.*
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.random.Random
import kotlin.uuid.ExperimentalUuidApi
/**
* Driving Adapter (REST Controller).
@@ -20,6 +22,7 @@ import kotlin.random.Random
@RestController
// Spring requires using `originPatterns` (not wildcard `origins`) when credentials are enabled.
@CrossOrigin(allowedHeaders = ["*"], allowCredentials = "true", originPatterns = ["*"])
@OptIn(ExperimentalUuidApi::class)
class PingController(
private val pingUseCase: PingUseCase
) : PingApi {
@@ -75,6 +78,19 @@ class PingController(
return createResponse(domainPing, "secure-pong")
}
@GetMapping("/ping/sync")
override suspend fun syncPings(
@RequestParam(required = false, defaultValue = "0") lastSyncTimestamp: Long
): List<PingEvent> {
return pingUseCase.getPingsSince(lastSyncTimestamp).map {
PingEvent(
id = it.id.toString(),
message = it.message,
lastModified = it.timestamp.toEpochMilli()
)
}
}
// Helper
private fun createResponse(domainPing: at.mocode.ping.domain.Ping, status: String) = PingResponse(
status = status,