From 5f4d8ba747dee47f30c509cbe9d41dd4532df397 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 00:42:14 +0000 Subject: [PATCH 1/2] test(quartz): widen NIP-40 expiration buffer to outlive insert race The reject_expired_events trigger checks NEW.expiration <= unixepoch() at insert time, so signing/insert latency on a slow runner could push unixepoch() to time+1 before the row was committed, raising SQLiteException at runBlocking entry. Bumping the short event to time+5 with a matching delay(6000) mirrors ExpirationTest.testDeletingExpiredEvents and gives the trigger room to breathe without changing the behavior under test. --- .../cache/projection/EventStoreProjectionTest.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt index 126cac4d8..7d97d35af 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt @@ -401,7 +401,11 @@ class EventStoreProjectionTest { val time = TimeUtils.now() val safe = signer.sign(TextNoteEvent.build("safe", createdAt = time) { expiration(time + 100) }) observable.insert(safe) - val short = signer.sign(TextNoteEvent.build("short", createdAt = time) { expiration(time + 1) }) + // The SQL trigger rejects an insert whose expiration is + // already <= unixepoch(), so this buffer must comfortably + // outlive any signing/insert latency. Mirrors the pattern + // in ExpirationTest.testDeletingExpiredEvents. + val short = signer.sign(TextNoteEvent.build("short", createdAt = time) { expiration(time + 5) }) observable.insert(short) val projection = @@ -412,7 +416,7 @@ class EventStoreProjectionTest { // Let the short expiration lapse, then ask the store to // sweep — the projection drops the expired slot in // response to the resulting StoreChange.Delete(Expired). - delay(2000) + delay(6000) observable.deleteExpiredEvents() val after = projection.awaitItems { it.size == 1 } From d6be9e45746553f143b0812f7f45ae8e23273950 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 00:50:30 +0000 Subject: [PATCH 2/2] test(quartz): drive nip40 expiration test through projection directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the wall-clock dance with a direct EventStoreProjection construction wired to a controllable nowProvider. Both events are inserted with expirations safely in the future so the SQL reject_expired_events trigger never fires; the test then bumps the fake clock and replays the StoreChange.DeleteExpired the observable would emit after a sweep. Tests the projection's reaction to the sweep signal — which is what this case was always about — without a 6s real-time delay or a flake window. Full SQL sweep behavior remains covered by ExpirationTest.testDeletingExpiredEvents. --- .../projection/EventStoreProjectionTest.kt | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt index 7d97d35af..f7d5b7b0f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore +import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore.StoreChange import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent @@ -393,33 +394,41 @@ class EventStoreProjectionTest { /** * NIP-40 expiration drops slots only when the application calls * `deleteExpiredEvents()` on the observable store — projections - * no longer run their own ticker. + * no longer run their own ticker. Driven through the projection + * directly with a controllable clock so the SQL trigger's wall- + * clock check (`<= unixepoch()`) doesn't race the inserts and we + * don't need a real-time delay to simulate expiration. The full + * SQL sweep path is covered by `ExpirationTest.testDeletingExpiredEvents`. */ @Test fun nip40ExpirationDroppedOnStoreSweep() = runBlocking { val time = TimeUtils.now() + // Both expirations safely in the future so the SQL + // trigger accepts both inserts unconditionally. val safe = signer.sign(TextNoteEvent.build("safe", createdAt = time) { expiration(time + 100) }) + val short = signer.sign(TextNoteEvent.build("short", createdAt = time) { expiration(time + 50) }) observable.insert(safe) - // The SQL trigger rejects an insert whose expiration is - // already <= unixepoch(), so this buffer must comfortably - // outlive any signing/insert latency. Mirrors the pattern - // in ExpirationTest.testDeletingExpiredEvents. - val short = signer.sign(TextNoteEvent.build("short", createdAt = time) { expiration(time + 5) }) observable.insert(short) + var fakeNow = time val projection = - projectionOf(Filter(kinds = listOf(TextNoteEvent.KIND))) - projection.awaitLoaded() - assertEquals(2, projection.items.size) + EventStoreProjection( + observable, + listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), + nowProvider = { fakeNow }, + ) + projection.seed() + assertEquals(2, projection.snapshot().items.size) - // Let the short expiration lapse, then ask the store to - // sweep — the projection drops the expired slot in - // response to the resulting StoreChange.Delete(Expired). - delay(6000) - observable.deleteExpiredEvents() + // Jump the clock past `short`'s expiration and replay the + // StoreChange.DeleteExpired the observable would emit + // after a sweep at fakeNow. + fakeNow = time + 75 + projection.apply(StoreChange.DeleteExpired(asOf = fakeNow)) - val after = projection.awaitItems { it.size == 1 } + val after = projection.snapshot().items + assertEquals(1, after.size) assertEquals(safe.id, after[0].value.id) }