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
@@ -4,6 +4,7 @@ import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingEvent
import at.mocode.shared.core.AppConstants
import io.ktor.client.*
import io.ktor.client.call.*
@@ -52,4 +53,10 @@ class PingApiClient(
override suspend fun securePing(): PingResponse {
return client.get("$baseUrl/api/ping/secure").body()
}
override suspend fun syncPings(lastSyncTimestamp: Long): List<PingEvent> {
return client.get("$baseUrl/api/ping/sync") {
parameter("lastSyncTimestamp", lastSyncTimestamp)
}.body()
}
}
@@ -3,6 +3,7 @@ package at.mocode.clients.pingfeature
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 io.ktor.client.HttpClient
import io.ktor.client.call.body
@@ -34,4 +35,10 @@ class PingApiKoinClient(private val client: HttpClient) : PingApi {
override suspend fun securePing(): PingResponse {
return client.get("/api/ping/secure").body()
}
override suspend fun syncPings(lastSyncTimestamp: Long): List<PingEvent> {
return client.get("/api/ping/sync") {
url.parameters.append("lastSyncTimestamp", lastSyncTimestamp.toString())
}.body()
}
}
@@ -4,6 +4,7 @@ import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingEvent
/**
* Test double implementation of PingApi for testing purposes.
@@ -23,6 +24,7 @@ class TestPingApiClient : PingApi {
var healthResponse: HealthResponse? = null
var publicPingResponse: PingResponse? = null
var securePingResponse: PingResponse? = null
var syncPingsResponse: List<PingEvent> = emptyList()
// Call tracking
var simplePingCalled = false
@@ -30,6 +32,7 @@ class TestPingApiClient : PingApi {
var healthCheckCalled = false
var publicPingCalled = false
var securePingCalled = false
var syncPingsCalledWith: Long? = null
var callCount = 0
override suspend fun simplePing(): PingResponse {
@@ -91,6 +94,21 @@ class TestPingApiClient : PingApi {
return handleRequest(securePingResponse)
}
override suspend fun syncPings(lastSyncTimestamp: Long): List<PingEvent> {
syncPingsCalledWith = lastSyncTimestamp
callCount++
if (simulateDelay) {
kotlinx.coroutines.delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return syncPingsResponse
}
private suspend fun handleRequest(response: PingResponse?): PingResponse {
if (simulateDelay) {
kotlinx.coroutines.delay(delayMs)
@@ -118,11 +136,13 @@ class TestPingApiClient : PingApi {
healthResponse = null
publicPingResponse = null
securePingResponse = null
syncPingsResponse = emptyList()
simplePingCalled = false
enhancedPingCalledWith = null
healthCheckCalled = false
publicPingCalled = false
securePingCalled = false
syncPingsCalledWith = null
callCount = 0
}
}
@@ -1,30 +1,26 @@
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document
import org.w3c.dom.HTMLElement
import at.mocode.shared.di.initKoin
import at.mocode.frontend.core.network.networkModule
import at.mocode.clients.authfeature.di.authFeatureModule
import at.mocode.frontend.core.localdb.localDbModule
import at.mocode.frontend.core.localdb.DatabaseProvider
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.clients.pingfeature.di.pingFeatureModule
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.localdb.DatabaseProvider
import at.mocode.frontend.core.localdb.localDbModule
import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.ping.feature.di.pingSyncFeatureModule
import navigation.navigationModule
import at.mocode.shared.di.initKoin
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import kotlinx.browser.document
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import navigation.navigationModule
import org.koin.core.context.GlobalContext
import org.koin.core.context.GlobalContext.get
import org.koin.core.qualifier.named
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.Koin
import org.koin.core.context.loadKoinModules
import org.koin.core.qualifier.named
import org.koin.dsl.module
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import org.w3c.dom.HTMLElement
@OptIn(ExperimentalComposeUiApi::class)
fun main() {