feat(quartz): FsEventStore NIP-62 right-to-vanish (step 6)

Adds tombstones/vanish/<owner_hex>.json hardlinks (one per owner,
strongest cutoff wins) plus the cascade and block-future-insert
semantics from SQLite's RightToVanishModule.

- FsLayout: vanishTombstonePath helper + tombstones/vanish/ skeleton.
- FsTombstones: vanishCutoff / installVanish / clearVanish — install
  uses atomic rename-with-REPLACE_EXISTING and only proceeds when the
  new kind-62's createdAt is strictly greater than any existing tomb.
- FsEventStore:
  - constructor now takes optional relay: NormalizedRelayUrl? to scope
    NIP-62 cascades (matches SQLiteEventStore's relay arg).
  - isBlockedByTombstone now also rejects events whose owner has an
    active vanish with createdAt >= event.createdAt — owner is the
    recipient for GiftWrap, matching pubkey_owner_hash semantics.
  - processVanish() runs after canonical write: skips if !shouldVanish-
    From(relay), installs the tombstone, then walks idx/owner/<hex>/
    and deletes every event with ts < vanish.createdAt. The vanish
    event itself survives (its ts equals the cutoff).

Tests: 11 new in FsVanishTest — relay-scoped cascade, different-relay
no-op, vanishFromEverywhere, block re-insert + newer-passes, equal
ts blocked, other authors unaffected, stronger cutoff wins, weaker
ignored, tombstone shares inode with kind-62 canonical, kind-62 stays
queryable. 70 fs tests green.
This commit is contained in:
Claude
2026-04-24 23:31:14 +00:00
parent 53cae2ed09
commit 8c2b2f65a9
4 changed files with 359 additions and 0 deletions
@@ -27,12 +27,14 @@ import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
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.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import java.nio.file.FileAlreadyExistsException
import java.nio.file.Files
@@ -63,6 +65,13 @@ class FsEventStore(
* 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
* `null`, only "ALL_RELAYS" vanish requests cascade — matches
* `SQLiteEventStore`'s relay arg semantics.
*/
private val relay: NormalizedRelayUrl? = null,
) : IEventStore {
private val layout = FsLayout(root)
private val hasher: TagNameValueHasher
@@ -108,6 +117,7 @@ class FsEventStore(
slots.install(slot, canonical, event, existingSlot)
}
if (event is DeletionEvent) processDeletion(event, canonical)
if (event is RequestToVanishEvent) processVanish(event, canonical)
return
}
@@ -129,6 +139,7 @@ class FsEventStore(
slots.install(slot, canonical, event, existingSlot)
}
if (event is DeletionEvent) processDeletion(event, canonical)
if (event is RequestToVanishEvent) processVanish(event, canonical)
} catch (t: Throwable) {
Files.deleteIfExists(tmp)
throw t
@@ -163,6 +174,11 @@ class FsEventStore(
val cutoff = tombstones.addrTombstoneCutoff(event.kind, event.pubKey, "")
if (cutoff != null && event.createdAt <= cutoff) return true
}
// NIP-62: any event whose owner has an active vanish request with
// createdAt >= event.createdAt is rejected. Owner is the recipient
// for GiftWrap, matching SQLite's `pubkey_owner_hash`.
val vanishCutoff = tombstones.vanishCutoff(indexer.ownerHash(event))
if (vanishCutoff != null && event.createdAt <= vanishCutoff) return true
return false
}
@@ -315,6 +331,36 @@ class FsEventStore(
return if (canonical.deleteIfExists()) 1 else 0
}
/**
* NIP-62 right-to-vanish processing. When the kind-62 event scopes
* to this store's relay (or "ALL_RELAYS"), install/replace the
* vanish tombstone keyed by owner hash and cascade-delete every
* event from that owner with `createdAt < vanish.createdAt`. Mirrors
* SQLite's `delete_events_on_event_vanish` AFTER-INSERT trigger.
*/
private fun processVanish(
event: RequestToVanishEvent,
canonical: java.nio.file.Path,
) {
if (!event.shouldVanishFrom(relay)) return
val ownerHash = hasher.hash(event.pubKey)
if (!tombstones.installVanish(ownerHash, event, canonical)) return
// Cascade: walk idx/owner/<ownerHex>/, delete every event whose
// ts < vanish.createdAt. The vanish event's own ts equals
// vanish.createdAt so it survives.
val ownerDir = layout.idxOwner.resolve(FsLayout.hashHex(ownerHash))
if (!Files.isDirectory(ownerDir)) return
val toDelete = ArrayList<HexKey>()
Files.list(ownerDir).use { stream ->
for (entry in stream) {
val parsed = FsLayout.parseEntry(entry.fileName.toString()) ?: continue
if (parsed.first < event.createdAt) toDelete.add(parsed.second)
}
}
toDelete.forEach { delete(it) }
}
/**
* Sweep expired events. Walks `idx/expires_at/`, parses `<exp>-<id>`
* filenames, and deletes any entry whose `exp < now`. Matches SQLite's
@@ -51,6 +51,7 @@ internal class FsLayout(
val tombstones: Path = root.resolve(TOMBSTONES_DIR)
val tombstonesId: Path = tombstones.resolve(TOMB_ID)
val tombstonesAddr: Path = tombstones.resolve(TOMB_ADDR)
val tombstonesVanish: Path = tombstones.resolve(TOMB_VANISH)
fun canonical(id: HexKey): Path {
require(id.length >= 4) { "event id must be at least 4 hex chars, got '$id'" }
@@ -127,6 +128,9 @@ internal class FsLayout(
dTag: String,
): Path = tombstonesAddr.resolve(kind.toString()).resolve(pubkey).resolve("${sha256Hex(dTag)}$JSON_EXT")
/** NIP-62 vanish tombstone keyed by owner hash (matches `pubkey_owner_hash`). */
fun vanishTombstonePath(ownerHash: Long): Path = tombstonesVanish.resolve("${hashHex(ownerHash)}$JSON_EXT")
fun ensureSkeleton() {
Files.createDirectories(events)
Files.createDirectories(staging)
@@ -139,6 +143,7 @@ internal class FsLayout(
Files.createDirectories(addressable)
Files.createDirectories(tombstonesId)
Files.createDirectories(tombstonesAddr)
Files.createDirectories(tombstonesVanish)
}
/**
@@ -174,6 +179,7 @@ internal class FsLayout(
const val TOMBSTONES_DIR = "tombstones"
const val TOMB_ID = "id"
const val TOMB_ADDR = "addr"
const val TOMB_VANISH = "vanish"
const val SEED_FILE = ".seed"
const val JSON_EXT = ".json"
@@ -124,4 +124,52 @@ internal class FsTombstones(
) {
Files.deleteIfExists(layout.addrTombstonePath(kind, pubkey, dTag))
}
// ------------------------------------------------------------------
// NIP-62 vanish tombstones (one per owner; latest wins)
// ------------------------------------------------------------------
/** Vanish cutoff for the given owner, or null if no tombstone. */
fun vanishCutoff(ownerHash: Long): Long? {
val path = layout.vanishTombstonePath(ownerHash)
if (!path.exists()) return null
return try {
Event.fromJson(path.readText()).createdAt
} catch (_: java.nio.file.NoSuchFileException) {
null
} catch (_: Exception) {
null
}
}
/**
* Install a vanish tombstone, atomically replacing any existing one
* with a strictly older `createdAt`. Returns true when this call
* actually installed a new (or stronger) tombstone — the caller
* should run the cascade only when this is true.
*/
fun installVanish(
ownerHash: Long,
vanishEvent: Event,
canonical: Path,
): Boolean {
val path = layout.vanishTombstonePath(ownerHash)
val existing = vanishCutoff(ownerHash)
if (existing != null && existing >= vanishEvent.createdAt) return false
Files.createDirectories(path.parent)
val tmp = path.resolveSibling("${path.fileName}.tmp.${UUID.randomUUID()}")
try {
Files.createLink(tmp, canonical)
Files.move(tmp, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
} catch (t: Throwable) {
Files.deleteIfExists(tmp)
throw t
}
return true
}
fun clearVanish(ownerHash: Long) {
Files.deleteIfExists(layout.vanishTombstonePath(ownerHash))
}
}
@@ -0,0 +1,259 @@
/*
* 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.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
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 FsVanishTest {
private val signer = NostrSignerSync()
private val otherSigner = NostrSignerSync()
private val storeRelay = "wss://quartz.local".normalizeRelayUrl()
private lateinit var root: Path
private lateinit var store: FsEventStore
@BeforeTest
fun setup() {
Secp256k1Instance
root = Files.createTempDirectory("fs-van-")
store = FsEventStore(root, relay = storeRelay)
}
@AfterTest
fun tearDown() {
store.close()
if (root.exists()) {
Files.walk(root).use { it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } }
}
}
private fun note(
body: String,
ts: Long,
s: NostrSignerSync = signer,
) = s.sign<TextNoteEvent>(TextNoteEvent.build(body, createdAt = ts))
private fun vanish(
ts: Long,
relayUrl: String = storeRelay.url,
s: NostrSignerSync = signer,
) = s.sign<RequestToVanishEvent>(RequestToVanishEvent.build(relayUrl.normalizeRelayUrl(), createdAt = ts))
private fun vanishEverywhere(
ts: Long,
s: NostrSignerSync = signer,
) = s.sign<RequestToVanishEvent>(RequestToVanishEvent.buildVanishFromEverywhere(createdAt = ts))
// ------------------------------------------------------------------
// Cascade
// ------------------------------------------------------------------
@Test
fun `vanish for this relay cascades older events from the same author`() {
val n1 = note("a", 10)
val n2 = note("b", 20)
val n3 = note("c", 30) // same ts as vanish — survives because cascade uses strict <
store.insert(n1)
store.insert(n2)
store.insert(n3)
val v = vanish(ts = 30)
store.insert(v)
assertFalse(store.hasCanonical(n1.id), "n1 should be cascade-deleted")
assertFalse(store.hasCanonical(n2.id), "n2 should be cascade-deleted")
assertTrue(store.hasCanonical(n3.id), "n3 (createdAt == vanish.createdAt) survives")
assertTrue(store.hasCanonical(v.id))
}
@Test
fun `vanish for a different relay does NOT cascade`() {
val n = note("x", 10)
store.insert(n)
val v = vanish(ts = 20, relayUrl = "wss://elsewhere.example")
store.insert(v)
assertTrue(store.hasCanonical(n.id), "vanish scoped to another relay must not cascade")
// The kind-62 event itself is still persisted (it's just a normal event).
assertTrue(store.hasCanonical(v.id))
// No vanish tombstone installed.
val tombDir = root.resolve("tombstones/vanish")
if (tombDir.exists()) {
assertEquals(0, Files.list(tombDir).use { it.toList() }.size)
}
}
@Test
fun `vanishFromEverywhere always cascades regardless of relay`() {
val n = note("x", 10)
store.insert(n)
val v = vanishEverywhere(ts = 20)
store.insert(v)
assertFalse(store.hasCanonical(n.id))
}
// ------------------------------------------------------------------
// Block re-insert
// ------------------------------------------------------------------
@Test
fun `events older than vanish are blocked from re-insertion`() {
val n = note("a", 10)
store.insert(n)
val v = vanish(ts = 50)
store.insert(v)
// Re-insert blocked.
store.insert(n)
assertFalse(store.hasCanonical(n.id))
// A brand-new older event by the same author also blocked.
val older = note("older", 5)
store.insert(older)
assertFalse(store.hasCanonical(older.id))
}
@Test
fun `events at vanish ts are blocked, parity with SQLite`() {
val v = vanish(ts = 50)
store.insert(v)
val equal = note("equal", 50)
store.insert(equal)
assertFalse(store.hasCanonical(equal.id), "createdAt == vanish.createdAt should be blocked")
}
@Test
fun `events newer than vanish still pass`() {
val v = vanish(ts = 50)
store.insert(v)
val newer = note("newer", 100)
store.insert(newer)
assertTrue(store.hasCanonical(newer.id))
}
@Test
fun `another author is unaffected by my vanish`() {
val mine = note("mine", 10)
store.insert(mine)
val theirs = note("theirs", 5, s = otherSigner)
store.insert(theirs)
val v = vanish(ts = 50)
store.insert(v)
assertFalse(store.hasCanonical(mine.id), "my old event cascade-deleted")
assertTrue(store.hasCanonical(theirs.id), "other author's event is unaffected")
}
// ------------------------------------------------------------------
// Multiple vanish requests — strongest cutoff wins
// ------------------------------------------------------------------
@Test
fun `later vanish raises the cutoff`() {
val n100 = note("at-100", 100)
store.insert(n100)
store.insert(vanish(ts = 50))
// n100 still around because 100 > 50.
assertTrue(store.hasCanonical(n100.id))
// Stronger vanish at ts=200 cascades it.
store.insert(vanish(ts = 200))
assertFalse(store.hasCanonical(n100.id))
// And new events at ts=150 are now blocked.
val mid = note("mid", 150)
store.insert(mid)
assertFalse(store.hasCanonical(mid.id))
}
@Test
fun `earlier vanish does not lower a stronger cutoff`() {
store.insert(vanish(ts = 200))
store.insert(vanish(ts = 50)) // older — should be a no-op for the tombstone
val mid = note("mid", 150)
store.insert(mid)
assertFalse(store.hasCanonical(mid.id), "stronger cutoff stays at 200")
}
// ------------------------------------------------------------------
// Tombstone is a hardlink to the kind-62 event
// ------------------------------------------------------------------
@Test
fun `vanish tombstone shares an inode with the kind-62 event`() {
val v = vanish(ts = 30)
store.insert(v)
val tombDir = root.resolve("tombstones/vanish")
val entries = Files.list(tombDir).use { it.toList() }
assertEquals(1, entries.size)
val canonical = root.resolve("events/${v.id.substring(0, 2)}/${v.id.substring(2, 4)}/${v.id}.json")
val tombKey = Files.readAttributes(entries.single(), java.nio.file.attribute.BasicFileAttributes::class.java).fileKey()
val canKey = Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey()
assertEquals(canKey, tombKey, "vanish tombstone should be a hardlink to the kind-62 canonical")
}
// ------------------------------------------------------------------
// Vanish event itself remains queryable
// ------------------------------------------------------------------
@Test
fun `vanish event itself is indexed and queryable`() {
val v = vanish(ts = 30)
store.insert(v)
val byKind = store.query<Event>(Filter(kinds = listOf(RequestToVanishEvent.KIND)))
assertEquals(listOf(v.id), byKind.map { it.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()
}
}