feat(quartz): FsEventStore NIP-09 deletion + tombstones (step 4)

Tombstone files under tombstones/id/<id>.json and tombstones/addr/
<kind>/<pubkey>/<sha256(d)>.json, each a hardlink to the kind-5
event that authored the deletion. One source of truth: the tombstone
IS the deletion event, just indexed by target.

- FsTombstones — installs id tombstones unconditionally, installs
  addr tombstones with strongest-cutoff-wins semantics (later kind-5
  replaces earlier via atomic rename), and exposes hasIdTombstone /
  addrTombstoneCutoff for pre-insert checks.
- FsEventStore.insert — pre-insert guard: id tombstone always blocks;
  addr tombstone blocks when event.createdAt <= tomb.createdAt, matching
  SQLite's reject_deleted_events trigger. Kind-5 inserts trigger a
  cascade: for each e-tag target owned by the deletion author, unlink
  indexes + slot + canonical; for each a-tag (same pubkey), evict the
  slot winner if its createdAt <= deletion.createdAt. Tombstones are
  installed for every target regardless, so future re-inserts are
  blocked.

Tests: 11 new in FsDeletionTest — delete-by-id, block-reinsert-by-id,
non-author-deletion still installs tombstone but no cascade, cascade
addressable slot, newer-at-deleted-address passes, older blocked,
equal-timestamp blocked, later kind-5 raises cutoff, earlier kind-5
does not lower, deletion event stays queryable, tombstone shares
inode with kind-5 canonical. 51 fs tests green.
This commit is contained in:
Claude
2026-04-24 21:38:37 +00:00
parent 096aa88096
commit 721546b140
4 changed files with 502 additions and 0 deletions
@@ -0,0 +1,277 @@
/*
* 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.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
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 FsDeletionTest {
private val signer = NostrSignerSync()
private val otherSigner = NostrSignerSync()
private lateinit var root: Path
private lateinit var store: FsEventStore
@BeforeTest
fun setup() {
Secp256k1Instance
root = Files.createTempDirectory("fs-del-")
store = FsEventStore(root)
}
@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,
signer: NostrSignerSync = this.signer,
) = signer.sign<TextNoteEvent>(TextNoteEvent.build(body, createdAt = ts))
private fun article(
slug: String,
body: String,
ts: Long,
) = signer.sign<LongTextNoteEvent>(
createdAt = ts,
kind = LongTextNoteEvent.KIND,
tags = arrayOf(arrayOf("d", slug)),
content = body,
)
// ------------------------------------------------------------------
// Delete by id
// ------------------------------------------------------------------
@Test
fun `kind-5 cascade-deletes a target by id`() {
val n1 = note("one", 10)
val n2 = note("two", 20)
store.insert(n1)
store.insert(n2)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n1), createdAt = 30))
store.insert(del)
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(n1.id))).map { it.id })
assertEquals(listOf(n2.id), store.query<TextNoteEvent>(Filter(ids = listOf(n2.id))).map { it.id })
assertEquals(listOf(del.id), store.query<DeletionEvent>(Filter(ids = listOf(del.id))).map { it.id })
}
@Test
fun `deletion blocks re-insertion of the same id`() {
val n1 = note("one", 10)
store.insert(n1)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n1), createdAt = 30))
store.insert(del)
store.insert(n1) // should be blocked
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(n1.id))).map { it.id })
}
@Test
fun `deletion by non-author does not cascade but still installs id tombstone`() {
// other signer authors a note
val theirs = note("not yours", 10, signer = otherSigner)
store.insert(theirs)
// Our signer attempts to delete it.
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(theirs), createdAt = 30))
store.insert(del)
// Cascade did NOT run — not our author.
assertEquals(listOf(theirs.id), store.query<TextNoteEvent>(Filter(ids = listOf(theirs.id))).map { it.id })
// But a tombstone is installed for the id, blocking future re-inserts.
// Re-inserting the note is effectively a no-op because it already
// exists. Instead we delete then try to re-insert.
store.delete(theirs.id)
store.insert(theirs)
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(theirs.id))).map { it.id })
}
// ------------------------------------------------------------------
// Delete by address (addressable)
// ------------------------------------------------------------------
@Test
fun `kind-5 by address cascades addressable slot`() {
val v1 = article("intro", "draft 1", 10)
val v2 = article("intro", "draft 2", 20)
store.insert(v1)
store.insert(v2)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v2), createdAt = 30))
store.insert(del)
// Slot cleared, canonical removed, indexes gone.
val dHash = FsLayout.sha256Hex("intro")
val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json")
assertFalse(slot.exists(), "addressable slot should be cleared")
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))).map { it.id })
}
@Test
fun `newer event at a deleted address may pass the cutoff`() {
val v1 = article("intro", "draft 1", 10)
store.insert(v1)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v1), createdAt = 20))
store.insert(del)
// A newer addressable at the same address should still be accepted.
val v3 = article("intro", "draft 3", 30)
store.insert(v3)
val got = store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)))
assertEquals(listOf(v3.id), got.map { it.id })
}
@Test
fun `older event at a deleted address is blocked by cutoff`() {
val v1 = article("intro", "draft 1", 10)
store.insert(v1)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v1), createdAt = 20))
store.insert(del)
// Attempting to re-insert an event authored earlier than the deletion should fail.
val older = article("intro", "even-older", 5)
store.insert(older)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(older.id))).map { it.id })
}
@Test
fun `equal-timestamp event at a deleted address is blocked`() {
val v = article("intro", "v", 10)
store.insert(v)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v), createdAt = 15))
store.insert(del)
val equal = article("intro", "equal", 15)
store.insert(equal)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(equal.id))).map { it.id })
}
// ------------------------------------------------------------------
// Multiple deletions: strongest cutoff wins
// ------------------------------------------------------------------
@Test
fun `later kind-5 raises the address cutoff`() {
val v = article("intro", "v", 10)
store.insert(v)
val del1 = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v), createdAt = 20))
store.insert(del1)
val del2Target = article("intro", "v2", 30) // inserted only to give del2 a target
store.insert(del2Target)
val del2 = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(del2Target), createdAt = 40))
store.insert(del2)
// Cutoff should now be 40, so an event at createdAt=35 is blocked.
val mid = article("intro", "mid", 35)
store.insert(mid)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(mid.id))).map { it.id })
}
@Test
fun `earlier kind-5 does not lower an existing stronger cutoff`() {
val v1 = article("slug", "v1", 10)
val v2 = article("slug", "v2", 20)
store.insert(v1)
store.insert(v2)
val strongDel = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v2), createdAt = 100))
store.insert(strongDel)
// Now insert a weaker (earlier) deletion for the same address.
val weakTarget = article("slug", "target-for-weak", 30)
store.insert(weakTarget) // this passes? No: cutoff=100, target@30 is blocked. Actually we want to
// construct a DeletionEvent that targets the slug address directly. The simplest way:
val weakDel =
signer.sign<DeletionEvent>(
DeletionEvent.buildAddressOnly(listOf(v1), createdAt = 50),
)
store.insert(weakDel)
// Cutoff should still be 100 — an event at 60 must still be blocked.
val blocked = article("slug", "should-be-blocked", 60)
store.insert(blocked)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(blocked.id))).map { it.id })
}
// ------------------------------------------------------------------
// Deletion event itself remains queryable
// ------------------------------------------------------------------
@Test
fun `deletion event itself is indexed and queryable`() {
val n = note("x", 10)
store.insert(n)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20))
store.insert(del)
val byKind = store.query<DeletionEvent>(Filter(kinds = listOf(DeletionEvent.KIND)))
assertEquals(listOf(del.id), byKind.map { it.id })
}
// ------------------------------------------------------------------
// Tombstone files use hardlinks to the kind-5 canonical
// ------------------------------------------------------------------
@Test
fun `id tombstone is a hardlink to the kind-5 event`() {
val n = note("x", 10)
store.insert(n)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20))
store.insert(del)
val tomb = root.resolve("tombstones/id/${n.id}.json")
assertTrue(tomb.exists())
val canonical = root.resolve("events/${del.id.substring(0, 2)}/${del.id.substring(2, 4)}/${del.id}.json")
assertEquals(
Files.readAttributes(tomb, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(),
Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(),
"tombstone and kind-5 canonical should share an inode",
)
}
}