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:
+35
-1
@@ -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"
|
||||
|
||||
+205
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user