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
|
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.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
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.isEphemeral
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
|
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
|
||||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy
|
import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy
|
||||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher
|
import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher
|
||||||
|
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||||
import java.nio.file.FileAlreadyExistsException
|
import java.nio.file.FileAlreadyExistsException
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
@@ -57,6 +61,7 @@ class FsEventStore(
|
|||||||
private val hasher: TagNameValueHasher
|
private val hasher: TagNameValueHasher
|
||||||
private val indexer: FsIndexer
|
private val indexer: FsIndexer
|
||||||
private val slots: FsSlots
|
private val slots: FsSlots
|
||||||
|
private val tombstones: FsTombstones
|
||||||
private val planner: FsQueryPlanner
|
private val planner: FsQueryPlanner
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -65,6 +70,7 @@ class FsEventStore(
|
|||||||
hasher = TagNameValueHasher(layout.readOrCreateSeed())
|
hasher = TagNameValueHasher(layout.readOrCreateSeed())
|
||||||
indexer = FsIndexer(layout, hasher, indexingStrategy)
|
indexer = FsIndexer(layout, hasher, indexingStrategy)
|
||||||
slots = FsSlots(layout, indexer)
|
slots = FsSlots(layout, indexer)
|
||||||
|
tombstones = FsTombstones(layout)
|
||||||
planner = FsQueryPlanner(layout, hasher)
|
planner = FsQueryPlanner(layout, hasher)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +80,7 @@ class FsEventStore(
|
|||||||
|
|
||||||
override fun insert(event: Event) {
|
override fun insert(event: Event) {
|
||||||
if (event.kind.isEphemeral()) return
|
if (event.kind.isEphemeral()) return
|
||||||
|
if (isBlockedByTombstone(event)) return
|
||||||
|
|
||||||
val slot = slots.slotPathFor(event)
|
val slot = slots.slotPathFor(event)
|
||||||
val existingSlot = slot?.let { slots.readSlot(it) }
|
val existingSlot = slot?.let { slots.readSlot(it) }
|
||||||
@@ -92,6 +99,7 @@ class FsEventStore(
|
|||||||
if (slot != null && existingSlot?.id != event.id) {
|
if (slot != null && existingSlot?.id != event.id) {
|
||||||
slots.install(slot, canonical, event, existingSlot)
|
slots.install(slot, canonical, event, existingSlot)
|
||||||
}
|
}
|
||||||
|
if (event is DeletionEvent) processDeletion(event, canonical)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,12 +120,81 @@ class FsEventStore(
|
|||||||
if (slot != null) {
|
if (slot != null) {
|
||||||
slots.install(slot, canonical, event, existingSlot)
|
slots.install(slot, canonical, event, existingSlot)
|
||||||
}
|
}
|
||||||
|
if (event is DeletionEvent) processDeletion(event, canonical)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Files.deleteIfExists(tmp)
|
Files.deleteIfExists(tmp)
|
||||||
throw t
|
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) {
|
override fun transaction(body: IEventStore.ITransaction.() -> Unit) {
|
||||||
val txn =
|
val txn =
|
||||||
object : IEventStore.ITransaction {
|
object : IEventStore.ITransaction {
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ internal class FsLayout(
|
|||||||
val idxTag: Path = idx.resolve(IDX_TAG)
|
val idxTag: Path = idx.resolve(IDX_TAG)
|
||||||
val replaceable: Path = root.resolve(REPLACEABLE_DIR)
|
val replaceable: Path = root.resolve(REPLACEABLE_DIR)
|
||||||
val addressable: Path = root.resolve(ADDRESSABLE_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 {
|
fun canonical(id: HexKey): Path {
|
||||||
require(id.length >= 4) { "event id must be at least 4 hex chars, got '$id'" }
|
require(id.length >= 4) { "event id must be at least 4 hex chars, got '$id'" }
|
||||||
@@ -104,6 +107,19 @@ internal class FsLayout(
|
|||||||
dTag: String,
|
dTag: String,
|
||||||
): Path = addressable.resolve(kind.toString()).resolve(pubkey).resolve("${sha256Hex(dTag)}$JSON_EXT")
|
): 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() {
|
fun ensureSkeleton() {
|
||||||
Files.createDirectories(events)
|
Files.createDirectories(events)
|
||||||
Files.createDirectories(staging)
|
Files.createDirectories(staging)
|
||||||
@@ -113,6 +129,8 @@ internal class FsLayout(
|
|||||||
Files.createDirectories(idxTag)
|
Files.createDirectories(idxTag)
|
||||||
Files.createDirectories(replaceable)
|
Files.createDirectories(replaceable)
|
||||||
Files.createDirectories(addressable)
|
Files.createDirectories(addressable)
|
||||||
|
Files.createDirectories(tombstonesId)
|
||||||
|
Files.createDirectories(tombstonesAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,6 +162,9 @@ internal class FsLayout(
|
|||||||
const val IDX_TAG = "tag"
|
const val IDX_TAG = "tag"
|
||||||
const val REPLACEABLE_DIR = "replaceable"
|
const val REPLACEABLE_DIR = "replaceable"
|
||||||
const val ADDRESSABLE_DIR = "addressable"
|
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 SEED_FILE = ".seed"
|
||||||
const val JSON_EXT = ".json"
|
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