refactor(quartz): swap FsEventStore clock ctor param for protected now()

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.
This commit is contained in:
Claude
2026-04-25 02:58:08 +00:00
parent d4d2fa4676
commit 265943907a
3 changed files with 29 additions and 12 deletions
@@ -57,14 +57,9 @@ import kotlin.io.path.readText
* addressable slots, NIP-09 tombstones, NIP-40 expiration, NIP-50 FTS, * addressable slots, NIP-09 tombstones, NIP-40 expiration, NIP-50 FTS,
* NIP-62 vanish, transactions, scrub. * NIP-62 vanish, transactions, scrub.
*/ */
class FsEventStore( open class FsEventStore(
val root: Path, val root: Path,
indexingStrategy: IndexingStrategy = DefaultIndexingStrategy(), 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 * Optional relay URL the store is acting on behalf of. Used by
* NIP-62 [RequestToVanishEvent.shouldVanishFrom] scoping. When * 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` * NIP-40 pre-insert guard. Parity with SQLite's `reject_expired_events`
* trigger: an event whose expiration tag is `<= now` is rejected * trigger: an event whose expiration tag is `<= now` is rejected
@@ -162,7 +165,7 @@ class FsEventStore(
private fun isAlreadyExpired(event: Event): Boolean { private fun isAlreadyExpired(event: Event): Boolean {
val exp = event.expiration() ?: return false val exp = event.expiration() ?: return false
if (exp <= 0) return false if (exp <= 0) return false
return exp <= clock() return exp <= now()
} }
/** /**
@@ -384,7 +387,7 @@ class FsEventStore(
override fun deleteExpiredEvents() = override fun deleteExpiredEvents() =
lockManager.withWriteLock { lockManager.withWriteLock {
if (!Files.isDirectory(layout.idxExpiresAt)) return@withWriteLock if (!Files.isDirectory(layout.idxExpiresAt)) return@withWriteLock
val now = clock() val now = now()
val toDelete = ArrayList<HexKey>() val toDelete = ArrayList<HexKey>()
Files.list(layout.idxExpiresAt).use { stream -> Files.list(layout.idxExpiresAt).use { stream ->
for (entry in stream) { for (entry in stream) {
@@ -130,8 +130,6 @@ val store = FsEventStore(
root = Path.of(System.getProperty("user.home"), ".amy", "events-store"), root = Path.of(System.getProperty("user.home"), ".amy", "events-store"),
// optional NIP-62 relay scoping; null = only ALL_RELAYS vanish requests cascade // optional NIP-62 relay scoping; null = only ALL_RELAYS vanish requests cascade
relay = null, 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 subsequent open — it salts every tag / owner hash, so it must persist
or every previously-written index entry becomes unreachable. 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 ### Insert
```kotlin ```kotlin
@@ -40,13 +40,24 @@ class FsExpirationTest {
private lateinit var root: Path private lateinit var root: Path
private var clockNow: Long = 1_000_000 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 @BeforeTest
fun setup() { fun setup() {
Secp256k1Instance Secp256k1Instance
root = Files.createTempDirectory("fs-exp-") root = Files.createTempDirectory("fs-exp-")
store = FsEventStore(root, clock = { clockNow }) store = ClockedStore(root) { clockNow }
} }
@AfterTest @AfterTest