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
@@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils
import java.nio.file.FileAlreadyExistsException
import java.nio.file.Files
import java.nio.file.Path
@@ -56,6 +58,11 @@ import kotlin.io.path.readText
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() },
) : IEventStore {
private val layout = FsLayout(root)
private val hasher: TagNameValueHasher
@@ -80,6 +87,7 @@ class FsEventStore(
override fun insert(event: Event) {
if (event.kind.isEphemeral()) return
if (isAlreadyExpired(event)) return
if (isBlockedByTombstone(event)) return
val slot = slots.slotPathFor(event)
@@ -127,6 +135,18 @@ class FsEventStore(
}
}
/**
* NIP-40 pre-insert guard. Parity with SQLite's `reject_expired_events`
* trigger: an event whose expiration tag is `<= now` is rejected
* outright. The `<= 0` clause matches the SQLite check that ignores
* non-positive expiration values.
*/
private fun isAlreadyExpired(event: Event): Boolean {
val exp = event.expiration() ?: return false
if (exp <= 0) return false
return exp <= clock()
}
/**
* NIP-09 pre-insert guard. Parity with SQLite's `reject_deleted_events`
* BEFORE INSERT trigger: id-scoped tombstones always block; address-
@@ -295,8 +315,22 @@ class FsEventStore(
return if (canonical.deleteIfExists()) 1 else 0
}
/**
* Sweep expired events. Walks `idx/expires_at/`, parses `<exp>-<id>`
* filenames, and deletes any entry whose `exp < now`. Matches SQLite's
* `expiration < unixepoch()` predicate (note: strict `<`, not `<=`).
*/
override fun deleteExpiredEvents() {
// Step-5 feature.
if (!Files.isDirectory(layout.idxExpiresAt)) return
val now = clock()
val toDelete = ArrayList<HexKey>()
Files.list(layout.idxExpiresAt).use { stream ->
for (entry in stream) {
val parsed = FsLayout.parseEntry(entry.fileName.toString()) ?: continue
if (parsed.first < now) toDelete.add(parsed.second)
}
}
toDelete.forEach { delete(it) }
}
override fun close() {
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import java.nio.file.FileAlreadyExistsException
import java.nio.file.Files
@@ -62,6 +63,10 @@ internal class FsIndexer(
val h = hasher.hash(tag[0], tag[1])
out.add(layout.tagEntry(tag[0], h, event.createdAt, event.id))
}
val exp = event.expiration()
if (exp != null && exp > 0) {
out.add(layout.expirationEntry(exp, event.id))
}
return out
}
@@ -45,6 +45,7 @@ internal class FsLayout(
val idxAuthor: Path = idx.resolve(IDX_AUTHOR)
val idxOwner: Path = idx.resolve(IDX_OWNER)
val idxTag: Path = idx.resolve(IDX_TAG)
val idxExpiresAt: Path = idx.resolve(IDX_EXPIRES_AT)
val replaceable: Path = root.resolve(REPLACEABLE_DIR)
val addressable: Path = root.resolve(ADDRESSABLE_DIR)
val tombstones: Path = root.resolve(TOMBSTONES_DIR)
@@ -81,6 +82,12 @@ internal class FsLayout(
id: HexKey,
): Path = idxTag.resolve(name).resolve(hashHex(valueHash)).resolve(entryName(ts, id))
/** NIP-40 expiration index entry. `exp` is unix seconds, padded for sort order. */
fun expirationEntry(
exp: Long,
id: HexKey,
): Path = idxExpiresAt.resolve(entryName(exp, id))
/** Directory that holds every indexed value for a tag name. */
fun tagDir(name: String): Path = idxTag.resolve(name)
@@ -127,6 +134,7 @@ internal class FsLayout(
Files.createDirectories(idxAuthor)
Files.createDirectories(idxOwner)
Files.createDirectories(idxTag)
Files.createDirectories(idxExpiresAt)
Files.createDirectories(replaceable)
Files.createDirectories(addressable)
Files.createDirectories(tombstonesId)
@@ -160,6 +168,7 @@ internal class FsLayout(
const val IDX_AUTHOR = "author"
const val IDX_OWNER = "owner"
const val IDX_TAG = "tag"
const val IDX_EXPIRES_AT = "expires_at"
const val REPLACEABLE_DIR = "replaceable"
const val ADDRESSABLE_DIR = "addressable"
const val TOMBSTONES_DIR = "tombstones"
@@ -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()
}
}