From 265943907a7a0b3bd91041125c45bd59ca4aa6c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 02:58:08 +0000 Subject: [PATCH] refactor(quartz): swap FsEventStore clock ctor param for protected now() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clock injection existed only for NIP-40 expiration tests. A public constructor parameter that ~every caller ignores is API clutter, so move it to a subclass seam: - FsEventStore is now `open class`; no more `clock: () -> Long` param. - `protected open fun now(): Long = TimeUtils.now()` is the override point. Production always takes the default; tests subclass. - FsExpirationTest gains a private `ClockedStore(root, source)` that overrides `now()`. Semantics unchanged, all 113 fs tests still green. Public `FsEventStore` ctor is now `(root, indexingStrategy, relay)` — no behavioural-drift surface that callers have to learn. --- .../quartz/nip01Core/store/fs/FsEventStore.kt | 19 +++++++++++-------- .../quartz/nip01Core/store/fs/README.md | 7 +++++-- .../nip01Core/store/fs/FsExpirationTest.kt | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt index ac3d5734c..0c14f8521 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -57,14 +57,9 @@ import kotlin.io.path.readText * addressable slots, NIP-09 tombstones, NIP-40 expiration, NIP-50 FTS, * NIP-62 vanish, transactions, scrub. */ -class FsEventStore( +open class FsEventStore( val root: Path, indexingStrategy: IndexingStrategy = DefaultIndexingStrategy(), - /** - * Source of "now" in unix seconds. Injectable so tests can drive - * NIP-40 expiration deterministically. Defaults to `TimeUtils.now()`. - */ - private val clock: () -> Long = { TimeUtils.now() }, /** * Optional relay URL the store is acting on behalf of. Used by * NIP-62 [RequestToVanishEvent.shouldVanishFrom] scoping. When @@ -153,6 +148,14 @@ class FsEventStore( } } + /** + * Source of "now" in unix seconds. Defaults to [TimeUtils.now]; tests + * override this via a subclass so NIP-40 expiration behaviour (insert + * guard and periodic sweep) can be driven deterministically without + * waiting on the wall clock or patching a global time source. + */ + protected open fun now(): Long = TimeUtils.now() + /** * NIP-40 pre-insert guard. Parity with SQLite's `reject_expired_events` * trigger: an event whose expiration tag is `<= now` is rejected @@ -162,7 +165,7 @@ class FsEventStore( private fun isAlreadyExpired(event: Event): Boolean { val exp = event.expiration() ?: return false if (exp <= 0) return false - return exp <= clock() + return exp <= now() } /** @@ -384,7 +387,7 @@ class FsEventStore( override fun deleteExpiredEvents() = lockManager.withWriteLock { if (!Files.isDirectory(layout.idxExpiresAt)) return@withWriteLock - val now = clock() + val now = now() val toDelete = ArrayList() Files.list(layout.idxExpiresAt).use { stream -> for (entry in stream) { diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md index 9ecede0a8..140d59c9d 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md @@ -130,8 +130,6 @@ val store = FsEventStore( root = Path.of(System.getProperty("user.home"), ".amy", "events-store"), // optional NIP-62 relay scoping; null = only ALL_RELAYS vanish requests cascade relay = null, - // optional clock for tests - clock = { com.vitorpamplona.quartz.utils.TimeUtils.now() }, ) ``` @@ -140,6 +138,11 @@ bytes) is generated atomically on first open and read on every subsequent open — it salts every tag / owner hash, so it must persist or every previously-written index entry becomes unreachable. +`FsEventStore` is `open` and exposes a `protected open fun now(): Long += TimeUtils.now()`. Tests override it with a subclass to drive NIP-40 +expiration at exact timestamps without relying on the wall clock; see +`FsExpirationTest.ClockedStore`. + ### Insert ```kotlin diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt index f0fd7c648..cb1f04f29 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsExpirationTest.kt @@ -40,13 +40,24 @@ class FsExpirationTest { private lateinit var root: Path private var clockNow: Long = 1_000_000 - private lateinit var store: FsEventStore + /** + * Test subclass overriding the `now()` seam so we can drive NIP-40 + * expiration at exact timestamps — no wall-clock sleeps, no flakes. + */ + private class ClockedStore( + root: Path, + private val source: () -> Long, + ) : FsEventStore(root) { + override fun now(): Long = source() + } + + private lateinit var store: ClockedStore @BeforeTest fun setup() { Secp256k1Instance root = Files.createTempDirectory("fs-exp-") - store = FsEventStore(root, clock = { clockNow }) + store = ClockedStore(root) { clockNow } } @AfterTest