feat(quartz): FsEventStore NIP-40 expiration (step 5)

Adds idx/expires_at/<padded_exp>-<id> hardlinks for events with an
expiration tag, an injectable clock so tests can drive time
deterministically, and the deleteExpiredEvents() sweep.

- FsLayout: idxExpiresAt + expirationEntry path helper.
- FsIndexer.pathsFor: emits the expiration entry whenever
  event.expiration() > 0, so insert / delete maintain it alongside
  the kind / author / owner / tag indexes.
- FsEventStore: pre-insert guard rejects events with exp <= now
  (SQLite parity: trigger uses inclusive <=). Constructor takes a
  clock function defaulting to TimeUtils.now(). deleteExpiredEvents
  walks idx/expires_at, parses filenames, and deletes anything with
  exp < now (strict <, matching SQLite's sweep query).

Tests: 8 new in FsExpirationTest — future expiration accepted +
indexed, already-expired-on-insert rejected, exp==now rejected on
insert but kept by sweep, non-positive exp ignored, sweep removes
canonical + index entries, plain events untouched. 59 fs tests green.
This commit is contained in:
Claude
2026-04-24 22:26:17 +00:00
parent 721546b140
commit 53cae2ed09
4 changed files with 254 additions and 1 deletions
@@ -0,0 +1,205 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.fs
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.listDirectoryEntries
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FsExpirationTest {
private val signer = NostrSignerSync()
private lateinit var root: Path
private var clockNow: Long = 1_000_000
private lateinit var store: FsEventStore
@BeforeTest
fun setup() {
Secp256k1Instance
root = Files.createTempDirectory("fs-exp-")
store = FsEventStore(root, clock = { clockNow })
}
@AfterTest
fun tearDown() {
store.close()
if (root.exists()) {
Files.walk(root).use { it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } }
}
}
private fun expiringNote(
body: String,
createdAt: Long,
expiresAt: Long,
): Event =
signer.sign(
createdAt = createdAt,
kind = 1,
tags = arrayOf(arrayOf("expiration", expiresAt.toString())),
content = body,
)
@Test
fun `event with future expiration is accepted and indexed`() {
clockNow = 1_000
val e = expiringNote("future", createdAt = 500, expiresAt = 2_000)
store.insert(e)
assertEquals(listOf(e.id), store.query<Event>(Filter(ids = listOf(e.id))).map { it.id })
val expIdx = root.resolve("idx/expires_at")
val entries = expIdx.listDirectoryEntries().map { it.fileName.toString() }
assertEquals(1, entries.size, "expires_at index should hold exactly one entry")
assertTrue(entries.single().endsWith("-${e.id}"))
assertTrue(entries.single().startsWith("0000002000"), "filename should be padded expiration ts")
}
@Test
fun `event already expired at insert time is rejected`() {
clockNow = 5_000
val e = expiringNote("dead-on-arrival", createdAt = 1_000, expiresAt = 4_000)
store.insert(e)
assertEquals(emptyList(), store.query<Event>(Filter(ids = listOf(e.id))).map { it.id })
assertFalse(store.hasCanonical(e.id))
}
@Test
fun `event with expiration equal to now is rejected (parity with SQLite trigger)`() {
clockNow = 5_000
val e = expiringNote("just-now", createdAt = 1_000, expiresAt = 5_000)
store.insert(e)
assertFalse(store.hasCanonical(e.id), "exp == now should be rejected (SQLite uses <=)")
}
@Test
fun `non-positive expiration is ignored`() {
clockNow = 5_000
val zero = expiringNote("zero", createdAt = 1, expiresAt = 0)
val neg = expiringNote("neg", createdAt = 2, expiresAt = -1)
store.insert(zero)
store.insert(neg)
assertTrue(store.hasCanonical(zero.id))
assertTrue(store.hasCanonical(neg.id))
// And nothing in idx/expires_at.
val expIdx = root.resolve("idx/expires_at")
assertEquals(0, expIdx.listDirectoryEntries().size, "non-positive exp should not be indexed")
}
@Test
fun `deleteExpiredEvents sweeps everything past now`() {
clockNow = 1_000
val a = expiringNote("a", createdAt = 100, expiresAt = 500) // already expired
val b = expiringNote("b", createdAt = 200, expiresAt = 999) // expired in past
val c = expiringNote("c", createdAt = 300, expiresAt = 2_000) // still alive
// Insert at a fake earlier "now" so all three pass the insert guard.
clockNow = 99
store.insert(a)
store.insert(b)
store.insert(c)
// Advance the clock and sweep.
clockNow = 1_000
store.deleteExpiredEvents()
assertFalse(store.hasCanonical(a.id), "a should be swept")
assertFalse(store.hasCanonical(b.id), "b should be swept")
assertTrue(store.hasCanonical(c.id), "c should survive")
}
@Test
fun `sweep uses strict less-than parity with SQLite`() {
// SQLite trigger: WHERE NEW.expiration <= unixepoch() (insert)
// SQLite sweep: WHERE expiration < unixepoch() (delete)
// Insert-time uses inclusive <=, sweep uses strict <.
clockNow = 50
val onTheTick = expiringNote("equal", createdAt = 10, expiresAt = 100)
store.insert(onTheTick)
clockNow = 100 // exp == now → sweep keeps it
store.deleteExpiredEvents()
assertTrue(store.hasCanonical(onTheTick.id), "exp == now should NOT be swept")
clockNow = 101
store.deleteExpiredEvents()
assertFalse(store.hasCanonical(onTheTick.id), "exp < now should be swept")
}
@Test
fun `sweep removes index entries too`() {
clockNow = 50
val e = expiringNote("x", createdAt = 1, expiresAt = 100)
store.insert(e)
val expIdx = root.resolve("idx/expires_at")
assertEquals(1, expIdx.listDirectoryEntries().size)
clockNow = 1_000
store.deleteExpiredEvents()
assertEquals(0, expIdx.listDirectoryEntries().size, "expires_at entry should be unlinked")
// Author + kind index entries also gone.
val authorDir = root.resolve("idx/author/${signer.pubKey}")
if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size)
}
@Test
fun `events without expiration are unaffected by sweep`() {
clockNow = 100
val plain =
signer.sign<Event>(
createdAt = 50,
kind = 1,
tags = emptyArray(),
content = "plain",
)
store.insert(plain)
clockNow = 1_000_000
store.deleteExpiredEvents()
assertTrue(store.hasCanonical(plain.id))
}
private fun FsEventStore.hasCanonical(id: String): Boolean {
val p =
root
.resolve("events")
.resolve(id.substring(0, 2))
.resolve(id.substring(2, 4))
.resolve("$id.json")
return p.exists()
}
}