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