From d8729c5bff2b21fee61352e1e26a1d3d5908f92b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 11:24:21 +0200 Subject: [PATCH] fix: sync group chat against live messages, not expired ones getMarmotGroupEvents is the local half of a negentropy exchange for the mlsMessages purpose: it answers "which kind-445 events for these rooms does this device already hold", and the answer is compared against the same question asked of the relay. Its expiry predicate read (expiresAt IS NULL OR expiresAt < :expiresAt) with :expiresAt bound to Clock.System.now(). That keeps a row whose expiry is in the PAST and drops every row still within its lifetime -- the exact inverse of what a relay serves. NIP-40 says an expiring event is one a relay should stop returning once its expiration tag has passed, so for every group message with an expiration the local set handed to negentropy was the complement of the relay's. The consequence is not a silent no-op. Reconciliation reports the symmetric difference, so an inverted set turns every live message into an id the relay believes we are missing (re-downloaded on every pass) and every expired message into an id we believe the relay is missing (queued for re-broadcast). Group chat therefore paid full transfer cost on every sync while pushing dead events back at the relay -- which is also why the bug was invisible: messages still arrived, just via the diff rather than the fast path. Flipped to `expiresAt > :now`, and the parameter renamed to `now` since it is the clock, not a bound on the column. ## Time bounds NIP-01 `since`/`until` are inclusive: `since <= created_at <= until`. The query used a strict `createdAt > :since` and had no `until` at all, so an event stamped exactly on the boundary was in the relay's set and not in ours, and everything newer than a requested `until` stayed in ours after the relay had excluded it. Both are now applied inclusively; the one call site passes Instant.DISTANT_FUTURE when the filter carries no upper bound. ## Ordering ORDER BY flipped to createdAt DESC. It is irrelevant when the caller asks for the whole set (negentropy sorts into its own vector regardless), but the parameter is a LIMIT: a relay answering a limited filter returns the NEWEST matching events, and ascending order returned the oldest. Not covered by tests: Room DAO behaviour needs a sqlite driver, which :composeApp:testDebugUnitTest does not have. Verified by KSP codegen -- the generated NostrEventDao_Impl carries the corrected predicate -- and compilation. Co-Authored-By: Claude Opus 5 --- .../compose/database/dao/NostrEventDao.kt | 21 ++++++++++++++++--- .../repository/DatabaseNostrRepository.kt | 1 + 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt index 321b8209..76a4c306 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt @@ -107,13 +107,28 @@ interface NostrEventDao { limit: Int ): List + /** + * The events a relay would still serve for `{"kinds":[445],"#h":[...]}`. + * + * `createdAt >= :since AND createdAt <= :until` because NIP-01 bounds are inclusive, and + * `expiresAt > :now` because a NIP-40 expiring event is one a relay has stopped serving. + * The predicate here used to read `expiresAt < :now`, which kept exactly the expired + * messages and dropped every live one — so for the whole group-chat sync path the set + * handed to negentropy was the complement of the relay's. + */ @Transaction - @Query("SELECT * FROM MarmotGroupEvent WHERE chatRoomId in (:chatRoomIds) AND createdAt > :since AND (expiresAt IS NULL OR expiresAt < :expiresAt) ORDER BY createdAt ASC LIMIT :limit") + @Query( + "SELECT * FROM MarmotGroupEvent WHERE chatRoomId in (:chatRoomIds) " + + "AND createdAt >= :since AND createdAt <= :until " + + "AND (expiresAt IS NULL OR expiresAt > :now) " + + "ORDER BY createdAt DESC LIMIT :limit" + ) fun getMarmotGroupEvents( chatRoomIds: Array, since: Instant, - expiresAt: Instant = Clock.System.now(), - limit: Int + until: Instant, + limit: Int, + now: Instant = Clock.System.now(), ): List @Transaction diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index b150fad6..d819381d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -718,6 +718,7 @@ class DatabaseNostrRepository( database.nostrEventDao().getMarmotGroupEvents( chatRoomIds = chatRoomIds, since = synchronizationFilter.since ?: GENESIS_AT, + until = synchronizationFilter.until ?: Instant.DISTANT_FUTURE, limit = if (applyLimits) { synchronizationFilter.limit ?: 50 } else {