From 721546b1406c5f434dd8ff89bcffd7e1466765bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 21:38:37 +0000 Subject: [PATCH] feat(quartz): FsEventStore NIP-09 deletion + tombstones (step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tombstone files under tombstones/id/.json and tombstones/addr/ //.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. --- .../quartz/nip01Core/store/fs/FsEventStore.kt | 77 +++++ .../quartz/nip01Core/store/fs/FsLayout.kt | 21 ++ .../quartz/nip01Core/store/fs/FsTombstones.kt | 127 ++++++++ .../nip01Core/store/fs/FsDeletionTest.kt | 277 ++++++++++++++++++ 4 files changed, 502 insertions(+) create mode 100644 quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsTombstones.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt index 1fb620a8b..5ce100503 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -20,14 +20,18 @@ */ package com.vitorpamplona.quartz.nip01Core.store.fs +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +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.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 java.nio.file.FileAlreadyExistsException import java.nio.file.Files import java.nio.file.Path @@ -57,6 +61,7 @@ class FsEventStore( private val hasher: TagNameValueHasher private val indexer: FsIndexer private val slots: FsSlots + private val tombstones: FsTombstones private val planner: FsQueryPlanner init { @@ -65,6 +70,7 @@ class FsEventStore( hasher = TagNameValueHasher(layout.readOrCreateSeed()) indexer = FsIndexer(layout, hasher, indexingStrategy) slots = FsSlots(layout, indexer) + tombstones = FsTombstones(layout) planner = FsQueryPlanner(layout, hasher) } @@ -74,6 +80,7 @@ class FsEventStore( override fun insert(event: Event) { if (event.kind.isEphemeral()) return + if (isBlockedByTombstone(event)) return val slot = slots.slotPathFor(event) val existingSlot = slot?.let { slots.readSlot(it) } @@ -92,6 +99,7 @@ class FsEventStore( if (slot != null && existingSlot?.id != event.id) { slots.install(slot, canonical, event, existingSlot) } + if (event is DeletionEvent) processDeletion(event, canonical) return } @@ -112,12 +120,81 @@ class FsEventStore( if (slot != null) { slots.install(slot, canonical, event, existingSlot) } + if (event is DeletionEvent) processDeletion(event, canonical) } catch (t: Throwable) { Files.deleteIfExists(tmp) throw t } } + /** + * NIP-09 pre-insert guard. Parity with SQLite's `reject_deleted_events` + * BEFORE INSERT trigger: id-scoped tombstones always block; address- + * scoped tombstones block when the existing cutoff is `>=` the new + * event's createdAt. + */ + private fun isBlockedByTombstone(event: Event): Boolean { + if (tombstones.hasIdTombstone(event.id)) return true + if (event is AddressableEvent && event.kind.isAddressable()) { + val cutoff = tombstones.addrTombstoneCutoff(event.kind, event.pubKey, event.dTag()) + if (cutoff != null && event.createdAt <= cutoff) return true + } + if (event.kind.isReplaceable()) { + val cutoff = tombstones.addrTombstoneCutoff(event.kind, event.pubKey, "") + if (cutoff != null && event.createdAt <= cutoff) return true + } + return false + } + + /** + * Applies a kind-5 deletion's side effects: cascade-deletes each + * target owned by the deletion author (matching SQLite's `pubkey_ + * owner_hash` check, which covers GiftWrap recipients), clears any + * slot the targets owned, and installs tombstone hardlinks so + * future re-inserts are blocked. + */ + private fun processDeletion( + deletion: DeletionEvent, + deletionCanonical: java.nio.file.Path, + ) { + val ownerHashOfDeletion = hasher.hash(deletion.pubKey) + + for (targetId in deletion.deleteEventIds()) { + val targetEvent = readEvent(targetId) + if (targetEvent != null && indexer.ownerHash(targetEvent) == ownerHashOfDeletion) { + indexer.unlink(targetEvent) + val targetSlot = slots.slotPathFor(targetEvent) + if (targetSlot != null && slots.readSlot(targetSlot)?.id == targetId) { + slots.clear(targetSlot) + } + layout.canonical(targetId).deleteIfExists() + } + tombstones.installId(targetId, deletionCanonical) + } + + for (addr in deletion.deleteAddresses()) { + // Cascade is only honoured when the address's author matches + // the deletion author — matching SQLite's WHERE pubkey = ?. + if (addr.pubKeyHex == deletion.pubKey) { + val slot = + when { + addr.kind.isReplaceable() -> layout.replaceableSlot(addr.kind, addr.pubKeyHex) + addr.kind.isAddressable() -> layout.addressableSlot(addr.kind, addr.pubKeyHex, addr.dTag) + else -> null + } + if (slot != null) { + val winner = slots.readSlot(slot) + if (winner != null && winner.createdAt <= deletion.createdAt) { + indexer.unlink(winner) + layout.canonical(winner.id).deleteIfExists() + slots.clear(slot) + } + } + } + tombstones.installAddr(addr.kind, addr.pubKeyHex, addr.dTag, deletion, deletionCanonical) + } + } + override fun transaction(body: IEventStore.ITransaction.() -> Unit) { val txn = object : IEventStore.ITransaction { diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLayout.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLayout.kt index 72d1640a9..7bca8ca88 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLayout.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLayout.kt @@ -47,6 +47,9 @@ internal class FsLayout( val idxTag: Path = idx.resolve(IDX_TAG) val replaceable: Path = root.resolve(REPLACEABLE_DIR) val addressable: Path = root.resolve(ADDRESSABLE_DIR) + val tombstones: Path = root.resolve(TOMBSTONES_DIR) + val tombstonesId: Path = tombstones.resolve(TOMB_ID) + val tombstonesAddr: Path = tombstones.resolve(TOMB_ADDR) fun canonical(id: HexKey): Path { require(id.length >= 4) { "event id must be at least 4 hex chars, got '$id'" } @@ -104,6 +107,19 @@ internal class FsLayout( dTag: String, ): Path = addressable.resolve(kind.toString()).resolve(pubkey).resolve("${sha256Hex(dTag)}$JSON_EXT") + /** NIP-09 tombstone path keyed by target event id. */ + fun idTombstonePath(id: HexKey): Path = tombstonesId.resolve("$id$JSON_EXT") + + /** + * NIP-09 tombstone path keyed by address. `dTag` may be empty — used + * for replaceable-kind tombstones that have no d-tag. + */ + fun addrTombstonePath( + kind: Kind, + pubkey: HexKey, + dTag: String, + ): Path = tombstonesAddr.resolve(kind.toString()).resolve(pubkey).resolve("${sha256Hex(dTag)}$JSON_EXT") + fun ensureSkeleton() { Files.createDirectories(events) Files.createDirectories(staging) @@ -113,6 +129,8 @@ internal class FsLayout( Files.createDirectories(idxTag) Files.createDirectories(replaceable) Files.createDirectories(addressable) + Files.createDirectories(tombstonesId) + Files.createDirectories(tombstonesAddr) } /** @@ -144,6 +162,9 @@ internal class FsLayout( const val IDX_TAG = "tag" const val REPLACEABLE_DIR = "replaceable" const val ADDRESSABLE_DIR = "addressable" + const val TOMBSTONES_DIR = "tombstones" + const val TOMB_ID = "id" + const val TOMB_ADDR = "addr" const val SEED_FILE = ".seed" const val JSON_EXT = ".json" diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsTombstones.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsTombstones.kt new file mode 100644 index 000000000..932737aa9 --- /dev/null +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsTombstones.kt @@ -0,0 +1,127 @@ +/* + * 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.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.UUID +import kotlin.io.path.exists +import kotlin.io.path.readText + +/** + * NIP-09 deletion tombstones. Each tombstone is itself a hardlink to + * the kind-5 event that authored the deletion — so the tombstone *is* + * the deletion event, just indexed by what it targets. + * + * Two tombstone kinds: + * tombstones/id/.json (NIP-09 by `e`-tag) + * tombstones/addr///.json (NIP-09 by `a`-tag; + * empty d for replaceables) + * + * Semantics: + * - An `id` tombstone unconditionally blocks re-insertion of the exact + * event id (matching SQLite's ExistsCheck on etag_hash). + * - An `addr` tombstone holds the kind-5 `createdAt` as a cutoff. An + * insert is blocked iff `event.createdAt <= tombstone.createdAt`. + * Newer events at the same address may pass (matching SQLite's + * `created_at >= NEW.created_at` trigger condition). + * - When multiple kind-5s target the same tombstone path, the one + * with the larger `createdAt` wins (strongest cutoff). Atomic + * rename swaps it in. + * + * The caller (FsEventStore) handles cascade-deleting the target events + * and slots; this class only manages the tombstone filesystem state. + */ +internal class FsTombstones( + private val layout: FsLayout, +) { + fun hasIdTombstone(id: HexKey): Boolean = layout.idTombstonePath(id).exists() + + /** Returns the `createdAt` cutoff from the tombstone, or null if none exists. */ + fun addrTombstoneCutoff( + kind: Kind, + pubkey: HexKey, + dTag: String, + ): Long? { + val path = layout.addrTombstonePath(kind, pubkey, dTag) + if (!path.exists()) return null + return try { + Event.fromJson(path.readText()).createdAt + } catch (_: java.nio.file.NoSuchFileException) { + null + } catch (_: Exception) { + null + } + } + + fun installId( + targetId: HexKey, + deletionCanonical: Path, + ) { + val path = layout.idTombstonePath(targetId) + if (path.exists()) return + Files.createDirectories(path.parent) + try { + Files.createLink(path, deletionCanonical) + } catch (_: FileAlreadyExistsException) { + // Concurrent writer installed first — fine, either copy is equivalent. + } + } + + fun installAddr( + kind: Kind, + pubkey: HexKey, + dTag: String, + deletionEvent: Event, + deletionCanonical: Path, + ) { + val path = layout.addrTombstonePath(kind, pubkey, dTag) + val existing = addrTombstoneCutoff(kind, pubkey, dTag) + if (existing != null && existing >= deletionEvent.createdAt) return + + Files.createDirectories(path.parent) + val tmp = path.resolveSibling("${path.fileName}.tmp.${UUID.randomUUID()}") + try { + Files.createLink(tmp, deletionCanonical) + Files.move(tmp, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (t: Throwable) { + Files.deleteIfExists(tmp) + throw t + } + } + + fun clearId(id: HexKey) { + Files.deleteIfExists(layout.idTombstonePath(id)) + } + + fun clearAddr( + kind: Kind, + pubkey: HexKey, + dTag: String, + ) { + Files.deleteIfExists(layout.addrTombstonePath(kind, pubkey, dTag)) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt new file mode 100644 index 000000000..2d475ecd6 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt @@ -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.build(body, createdAt = ts)) + + private fun article( + slug: String, + body: String, + ts: Long, + ) = signer.sign( + 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.build(listOf(n1), createdAt = 30)) + store.insert(del) + + assertEquals(emptyList(), store.query(Filter(ids = listOf(n1.id))).map { it.id }) + assertEquals(listOf(n2.id), store.query(Filter(ids = listOf(n2.id))).map { it.id }) + assertEquals(listOf(del.id), store.query(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.build(listOf(n1), createdAt = 30)) + store.insert(del) + + store.insert(n1) // should be blocked + assertEquals(emptyList(), store.query(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.build(listOf(theirs), createdAt = 30)) + store.insert(del) + + // Cascade did NOT run — not our author. + assertEquals(listOf(theirs.id), store.query(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(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.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(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.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(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.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(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.build(listOf(v), createdAt = 15)) + store.insert(del) + + val equal = article("intro", "equal", 15) + store.insert(equal) + assertEquals(emptyList(), store.query(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.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.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(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.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.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(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.build(listOf(n), createdAt = 20)) + store.insert(del) + + val byKind = store.query(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.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", + ) + } +}