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:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+127
@@ -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/<target_id>.json (NIP-09 by `e`-tag)
|
||||
* tombstones/addr/<kind>/<pubkey>/<sha256(d)>.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))
|
||||
}
|
||||
}
|
||||
+277
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user