feat(sync): Full-Inventory-Sync durch Delta-Sync ersetzen

Server + Client: Timestamp-basierter Delta-Sync als Alternative zum
Full-Sync. GET /api/inventory akzeptiert jetzt optionalen ?since=<ts>
Query-Parameter und liefert nur Items mit lastUpdated > since.

Shared: InventoryDto um deletedItemIds-Feld erweitert (Default: leer,
backward-compatible mit bestehenden Clients).

Server:
- DeletedItems-Tabelle trackt geloeschte Item-IDs pro Inventory
- InventoryRepository.loadInventorySince(): Delta-Query mit Items +
  deletedItemIds seit Timestamp
- saveInventory()/deleteItem(): Loeschungen werden in DeletedItems
  protokolliert
- DatabaseFactory: DeletedItems-Tabelle registriert

Client (App):
- SyncService.downloadInventory(since: Long?): optionaler since-Param
- SyncServiceImpl: haengt ?since= an GET-Request
- ItemDao.deleteByIds(): Batch-Loeschung fuer Delta-Sync
- ImportExportRepositoryImpl: verarbeitet deletedItemIds aus DTO
- SettingsViewModel.pullSync(fullSync): Delta-Sync mit letztem
  Sync-Timestamp; fullSyncRequired-Event loest weiterhin Full-Sync aus

Entscheidungen:
- Timestamp-basiert (nutzt bestehendes lastUpdated-Feld)
- Full-Sync bleibt Fallback (fullSyncRequired, erster Sync)
- Categories/Locations/Settings immer vollstaendig (klein)

6 neue DeltaSyncTests + 3 Repository-Tests + 2 SyncService-Tests

Closes #74
This commit is contained in:
Jens Reinemann 2026-05-17 03:15:49 +02:00
parent 75cfc41924
commit 0f25c180ed
16 changed files with 443 additions and 13 deletions

View file

@ -40,6 +40,9 @@ internal interface ItemDao {
@Upsert
suspend fun upsertAll(items: List<ItemEntity>)
@Query("DELETE FROM items WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
@Query("SELECT COUNT(*) FROM items WHERE category_id = :categoryId")
suspend fun countByCategoryId(categoryId: Int): Int

View file

@ -100,6 +100,9 @@ internal class ImportExportRepositoryImpl @Inject constructor(
transaction.execute {
categoryDao.upsertAll(dto.categories.map { CategoryEntity(id = it.id, name = it.name) })
locationDao.upsertAll(dto.locations.map { LocationEntity(id = it.id, name = it.name) })
if (dto.deletedItemIds.isNotEmpty()) {
itemDao.deleteByIds(dto.deletedItemIds)
}
itemDao.upsertAll(itemsToApply.map { item ->
ItemEntity(
id = item.id,

View file

@ -30,9 +30,13 @@ internal class SyncServiceImpl @Inject constructor(
private val settingsRepository: SettingsRepository
) : SyncService {
override suspend fun downloadInventory(): Result<InventoryDto> =
override suspend fun downloadInventory(since: Long?): Result<InventoryDto> =
executeRequest { serverUrl, token ->
val response = httpClient.get("$serverUrl/api/inventory") {
val url = buildString {
append("$serverUrl/api/inventory")
if (since != null) append("?since=$since")
}
val response = httpClient.get(url) {
header("Authorization", "Bearer $token")
}
handleResponse(response)

View file

@ -4,7 +4,7 @@ import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto
internal interface SyncService {
suspend fun downloadInventory(): Result<InventoryDto>
suspend fun downloadInventory(since: Long? = null): Result<InventoryDto>
suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto>
suspend fun login(serverUrl: String, username: String, password: String): Result<Unit>
suspend fun logout()

View file

@ -51,8 +51,8 @@ internal class SettingsViewModel @Inject constructor(
viewModelScope.launch {
webSocketClient.events.collect { event ->
when (event) {
is WebSocketEvent.FullSyncRequired -> pullSync()
is WebSocketEvent.InventoryUpdated -> pullSync()
is WebSocketEvent.FullSyncRequired -> pullSync(fullSync = true)
is WebSocketEvent.InventoryUpdated -> pullSync(fullSync = false)
is WebSocketEvent.ConnectionFailed -> {
_uiState.update { it.copy(syncStatus = SyncStatus.Error(event.message)) }
}
@ -336,11 +336,12 @@ internal class SettingsViewModel @Inject constructor(
}
}
fun pullSync() {
fun pullSync(fullSync: Boolean = false) {
viewModelScope.launch {
_uiState.update { it.copy(syncStatus = SyncStatus.Running) }
try {
val result = syncService.downloadInventory()
val since = if (fullSync) null else settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)?.toLongOrNull()
val result = syncService.downloadInventory(since)
result.fold(
onSuccess = { inventoryDto ->
importExportRepository.importFromInventoryDto(inventoryDto).fold(

View file

@ -87,6 +87,11 @@ internal class FakeItemDao : ItemDao {
override suspend fun updateLocationId(fromId: Int, toId: Int) = throw UnsupportedOperationException()
override suspend fun getLastUsedLocationId(): Int? = throw UnsupportedOperationException()
override suspend fun deleteByIds(ids: List<String>) {
items.removeAll { it.id in ids }
emit()
}
fun getItems(): List<ItemEntity> = items.toList()
}

View file

@ -100,6 +100,11 @@ private class FakeItemDao : ItemDao {
override suspend fun getLastUsedLocationId(): Int? =
items.maxByOrNull { it.lastUpdated }?.locationId
override suspend fun deleteByIds(ids: List<String>) {
items.removeAll { it.id in ids }
emit()
}
}
private class FakePendingSyncOpDao : PendingSyncOpDao {
@ -137,7 +142,7 @@ private class FakeSyncService : SyncService {
return deleteResult
}
override suspend fun downloadInventory(): Result<InventoryDto> = Result.success(
override suspend fun downloadInventory(since: Long?): Result<InventoryDto> = Result.success(
InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList())
)
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> =

View file

@ -191,6 +191,51 @@ class SyncServiceImplTest {
assertTrue(result.exceptionOrNull() is SyncError.NotConfigured)
}
@Test
fun test_downloadInventory_withSince_appendsQueryParam() = runTest {
// Given
setupSettings()
val engine = MockEngine { request ->
assertEquals("/api/inventory", request.url.encodedPath)
assertEquals("1715000000", request.url.parameters["since"])
respond(
content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
// When
val result = syncService.downloadInventory(since = 1715000000L)
// Then
assertTrue(result.isSuccess)
}
@Test
fun test_downloadInventory_withoutSince_noQueryParam() = runTest {
// Given
setupSettings()
val engine = MockEngine { request ->
assertTrue(request.url.parameters.isEmpty())
respond(
content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
// When
val result = syncService.downloadInventory(since = null)
// Then
assertTrue(result.isSuccess)
}
// --- uploadInventory ---
@Test

View file

@ -770,7 +770,7 @@ private class FakeSyncService : SyncService {
settings = emptyList()
)
override suspend fun downloadInventory(): Result<InventoryDto> {
override suspend fun downloadInventory(since: Long?): Result<InventoryDto> {
if (downloadShouldFail) return Result.failure(downloadError)
return Result.success(downloadResult)
}

View file

@ -42,8 +42,8 @@ internal object DatabaseFactory {
Database.connect(jdbcUrl, driver)
}
transaction {
SchemaUtils.create(Inventories, Users, Categories, Locations, Items, Settings, Messages)
SchemaUtils.createMissingTablesAndColumns(Inventories, Users, Categories, Locations, Items, Settings, Messages)
SchemaUtils.create(Inventories, Users, Categories, Locations, Items, Settings, Messages, DeletedItems)
SchemaUtils.createMissingTablesAndColumns(Inventories, Users, Categories, Locations, Items, Settings, Messages, DeletedItems)
}
migrateUserInventories()
seedAdmin(adminPassword)

View file

@ -72,3 +72,12 @@ internal object Messages : Table("messages") {
val deliveredAt = long("delivered_at").nullable()
override val primaryKey = PrimaryKey(id)
}
internal object DeletedItems : Table("deleted_items") {
val id = integer("id").autoIncrement()
val itemId = varchar("item_id", 36)
val inventoryId = varchar("inventory_id", 36)
val deletedAt = long("deleted_at")
override val primaryKey = PrimaryKey(id)
}

View file

@ -1,6 +1,7 @@
package de.krisenvorrat.server.repository
import de.krisenvorrat.server.db.Categories
import de.krisenvorrat.server.db.DeletedItems
import de.krisenvorrat.server.db.Inventories
import de.krisenvorrat.server.db.Items
import de.krisenvorrat.server.db.Locations
@ -108,6 +109,22 @@ internal class InventoryRepository {
fun saveInventory(inventoryId: String, inventory: InventoryDto) {
transaction {
val existingItemIds = Items.selectAll()
.where { Items.inventoryId eq inventoryId }
.map { it[Items.id] }
.toSet()
val incomingItemIds = inventory.items.map { it.id }.toSet()
val removedItemIds = existingItemIds - incomingItemIds
if (removedItemIds.isNotEmpty()) {
val now = System.currentTimeMillis()
for (removedId in removedItemIds) {
DeletedItems.insert {
it[itemId] = removedId
it[DeletedItems.inventoryId] = inventoryId
it[deletedAt] = now
}
}
}
Items.deleteWhere { Items.inventoryId eq inventoryId }
Settings.deleteWhere { Settings.inventoryId eq inventoryId }
Categories.deleteWhere { Categories.inventoryId eq inventoryId }
@ -269,10 +286,64 @@ internal class InventoryRepository {
val deleted = Items.deleteWhere {
(Items.id eq itemId) and (Items.inventoryId eq inventoryId)
}
if (deleted > 0) {
DeletedItems.insert {
it[DeletedItems.itemId] = itemId
it[DeletedItems.inventoryId] = inventoryId
it[deletedAt] = System.currentTimeMillis()
}
}
deleted > 0
}
}
fun loadInventorySince(inventoryId: String, since: Long): InventoryDto {
return transaction {
val categories = Categories.selectAll()
.where { Categories.inventoryId eq inventoryId }
.map { CategoryDto(id = it[Categories.id], name = it[Categories.name]) }
val locations = Locations.selectAll()
.where { Locations.inventoryId eq inventoryId }
.map { LocationDto(id = it[Locations.id], name = it[Locations.name]) }
val items = Items.selectAll()
.where { (Items.inventoryId eq inventoryId) and (Items.lastUpdated greater since) }
.map {
ItemDto(
id = it[Items.id],
name = it[Items.name],
categoryId = it[Items.categoryId],
quantity = it[Items.quantity],
unit = it[Items.unit],
unitPrice = it[Items.unitPrice],
kcalPerKg = it[Items.kcalPerKg],
expiryDate = it[Items.expiryDate],
locationId = it[Items.locationId],
notes = it[Items.notes],
lastUpdated = it[Items.lastUpdated]
)
}
val settings = Settings.selectAll()
.where { Settings.inventoryId eq inventoryId }
.map { SettingDto(key = it[Settings.key], value = it[Settings.value]) }
val deletedItemIds = DeletedItems.selectAll()
.where { (DeletedItems.inventoryId eq inventoryId) and (DeletedItems.deletedAt greater since) }
.map { it[DeletedItems.itemId] }
.distinct()
InventoryDto(
categories = categories,
locations = locations,
items = items,
settings = settings,
deletedItemIds = deletedItemIds
)
}
}
fun getAggregatedStats(): InventoryStatsDto {
return transaction {
val totalItems = Items.selectAll().count()

View file

@ -55,7 +55,12 @@ internal fun Route.inventoryRoutes(
val userId = call.principal<UserPrincipal>()?.userId
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
val inventoryId = repository.getEffectiveInventoryId(userId)
val inventory = repository.loadInventory(inventoryId)
val since = call.request.queryParameters["since"]?.toLongOrNull()
val inventory = if (since != null) {
repository.loadInventorySince(inventoryId, since)
} else {
repository.loadInventory(inventoryId)
}
call.respond(HttpStatusCode.OK, inventory)
}

View file

@ -0,0 +1,220 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
import de.krisenvorrat.shared.model.CategoryDto
import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto
import de.krisenvorrat.shared.model.LocationDto
import de.krisenvorrat.shared.model.SettingDto
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.config.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class DeltaSyncTest {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val token = createTestAccessToken()
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
environment {
config = MapApplicationConfig(*testMapConfig().toTypedArray())
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:delta_test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver",
adminPassword = "test-admin-pw"
)
configurePlugins()
}
block()
}
@Test
fun test_deltaPull_withoutSince_returnsFullInventory() = testApp {
// Given
val inventory = createInventory(lastUpdated = 1000L)
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventory))
}
// When pull without since (full sync)
val response = client.get("/api/inventory") { bearerAuth(token) }
// Then
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
assertEquals(2, result.items.size)
assertTrue(result.deletedItemIds.isEmpty())
}
@Test
fun test_deltaPull_withSince_returnsOnlyChangedItems() = testApp {
// Given push inventory with two items at t=1000
val inventory = createInventory(lastUpdated = 1000L)
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventory))
}
// When patch item-1 at t=2000
val patchBody = """{"name":"Dosenbrot aktualisiert","lastUpdated":2000}"""
client.patch("/api/inventory/items/item-1") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(patchBody)
}
// When delta pull since=1500
val response = client.get("/api/inventory?since=1500") { bearerAuth(token) }
// Then only item-1 returned (updated after since)
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
assertEquals(1, result.items.size)
assertEquals("item-1", result.items[0].id)
assertEquals("Dosenbrot aktualisiert", result.items[0].name)
}
@Test
fun test_deltaPull_withSince_returnsDeletedItemIds() = testApp {
// Given push inventory
val inventory = createInventory(lastUpdated = 1000L)
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventory))
}
// When delete item-2
val deleteResponse = client.delete("/api/inventory/items/item-2") { bearerAuth(token) }
assertEquals(HttpStatusCode.NoContent, deleteResponse.status)
// When delta pull since=500
val response = client.get("/api/inventory?since=500") { bearerAuth(token) }
// Then item-2 appears in deletedItemIds
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
assertTrue(result.deletedItemIds.contains("item-2"))
// item-1 should still be in items (lastUpdated=1000 > since=500)
assertEquals(1, result.items.size)
assertEquals("item-1", result.items[0].id)
}
@Test
fun test_deltaPull_withRecentSince_returnsNoItems() = testApp {
// Given push inventory with lastUpdated=1000
val inventory = createInventory(lastUpdated = 1000L)
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventory))
}
// When delta pull since=9999 (after all updates)
val response = client.get("/api/inventory?since=9999") { bearerAuth(token) }
// Then no items, no deletions
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
assertEquals(0, result.items.size)
assertTrue(result.deletedItemIds.isEmpty())
// Categories and locations always returned
assertEquals(1, result.categories.size)
assertEquals(1, result.locations.size)
}
@Test
fun test_deltaPull_saveInventoryTracksDeletedItems() = testApp {
// Given push inventory with 2 items
val inventory = createInventory(lastUpdated = 1000L)
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventory))
}
// When push again with only 1 item (item-2 removed)
val updatedInventory = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Konserven")),
locations = listOf(LocationDto(id = 1, name = "Keller")),
items = listOf(
ItemDto(
id = "item-1", name = "Dosenbrot", categoryId = 1,
quantity = 5.0, unit = "Stück", unitPrice = 3.99,
kcalPerKg = 250, expiryDate = "2027-06-15", locationId = 1,
notes = "", lastUpdated = 3000L
)
),
settings = emptyList()
)
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), updatedInventory))
}
// When delta pull since=500
val response = client.get("/api/inventory?since=500") { bearerAuth(token) }
// Then item-2 in deletedItemIds, item-1 in items
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
assertTrue(result.deletedItemIds.contains("item-2"))
assertEquals(1, result.items.size)
assertEquals("item-1", result.items[0].id)
}
@Test
fun test_deltaPull_invalidSinceParam_ignoredAsFullSync() = testApp {
// Given
val inventory = createInventory(lastUpdated = 1000L)
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventory))
}
// When since is not a valid number
val response = client.get("/api/inventory?since=abc") { bearerAuth(token) }
// Then treated as full sync
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
assertEquals(2, result.items.size)
}
private fun createInventory(lastUpdated: Long): InventoryDto = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Konserven")),
locations = listOf(LocationDto(id = 1, name = "Keller")),
items = listOf(
ItemDto(
id = "item-1", name = "Dosenbrot", categoryId = 1,
quantity = 5.0, unit = "Stück", unitPrice = 3.99,
kcalPerKg = 250, expiryDate = "2027-06-15", locationId = 1,
notes = "", lastUpdated = lastUpdated
),
ItemDto(
id = "item-2", name = "Mineralwasser", categoryId = 1,
quantity = 24.0, unit = "Liter", unitPrice = 0.49,
kcalPerKg = 0, expiryDate = "2028-01-01", locationId = 1,
notes = "", lastUpdated = lastUpdated
)
),
settings = emptyList()
)
}

View file

@ -228,4 +228,62 @@ class InventoryRepositoryTest {
assertEquals(2L, stats.recentTransactions)
assertTrue(stats.lastUpdated!! >= recentTimestamp)
}
@Test
fun test_loadInventorySince_returnsOnlyChangedItems() {
// Given
val inventory = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Test")),
locations = listOf(LocationDto(id = 1, name = "Test")),
items = listOf(
ItemDto(id = "old", name = "Old", categoryId = 1, quantity = 1.0, unit = "Stk",
unitPrice = 0.0, kcalPerKg = null, expiryDate = null, locationId = 1,
notes = "", lastUpdated = 1000L),
ItemDto(id = "new", name = "New", categoryId = 1, quantity = 1.0, unit = "Stk",
unitPrice = 0.0, kcalPerKg = null, expiryDate = null, locationId = 1,
notes = "", lastUpdated = 3000L)
),
settings = emptyList()
)
repository.saveInventory(testUserId, inventory)
// When
val result = repository.loadInventorySince(testUserId, 2000L)
// Then only the item with lastUpdated > 2000 is returned
assertEquals(1, result.items.size)
assertEquals("new", result.items[0].id)
// Note: deletedItemIds may contain entries from setUp clearing previous test data
}
@Test
fun test_deleteItem_trackedInDeletedItems() {
// Given
repository.saveInventory(testUserId, createTestInventory())
// When
val deleted = repository.deleteItem(testUserId, "item-1")
// Then
assertTrue(deleted)
val delta = repository.loadInventorySince(testUserId, 0L)
assertTrue(delta.deletedItemIds.contains("item-1"))
}
@Test
fun test_saveInventory_tracksRemovedItemsAsDeleted() {
// Given
repository.saveInventory(testUserId, createTestInventory())
// When save with no items (all removed)
val emptyInventory = InventoryDto(
categories = emptyList(), locations = emptyList(),
items = emptyList(), settings = emptyList()
)
repository.saveInventory(testUserId, emptyInventory)
// Then
val delta = repository.loadInventorySince(testUserId, 0L)
assertTrue(delta.deletedItemIds.contains("item-1"))
}
}

View file

@ -11,5 +11,6 @@ data class InventoryDto(
val categories: List<CategoryDto>,
val locations: List<LocationDto>,
val items: List<ItemDto>,
val settings: List<SettingDto>
val settings: List<SettingDto>,
val deletedItemIds: List<String> = emptyList()
)