mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
fix: sever child back-references when deleting a Note (NIP-09)
deleteNote() removed the target from its parents, gatherers, and the cache map, but never cleared its own child collections nor dropped itself from its children's replyTo. That left a partial deletion: every child kept the removed shell alive through replyTo (a leak), and a reply resolved later via computeReplyTo would getOrCreateNote a *second* Note for the same id — breaking the one-Note-per-id invariant. Adds Note.detachFromChildren(), which clears the note's forward child collections (via removeAllChildNotes) and severs this note from each child's replyTo (keeping any other parents). deleteNote() now calls it before notes.remove(), so once the note leaves the map nothing points at the dead shell. Orphaned replies become roots, which is correct once their parent is hard-deleted from the cache. Adds detachFromChildren coverage to NotePruningReferenceTest. https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
This commit is contained in:
@@ -1379,6 +1379,12 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
getAnyChannel(deleteNote)?.removeNote(deleteNote)
|
||||
|
||||
// Sever the back-references from this note's children before it leaves the
|
||||
// map. Otherwise each child keeps the removed shell alive through its replyTo
|
||||
// (a partial deletion / leak) and a reply resolved later via computeReplyTo
|
||||
// would resurrect a second Note for the same id.
|
||||
deleteNote.detachFromChildren()
|
||||
|
||||
notes.remove(deleteNote.idHex)
|
||||
|
||||
deleteNote.clearFlow()
|
||||
|
||||
@@ -417,6 +417,31 @@ open class Note(
|
||||
return toBeRemoved
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully detach this note from the notes below it in the graph so it can be
|
||||
* removed from the cache without leaving a partial deletion behind. It both
|
||||
* clears this note's own child collections (via [removeAllChildNotes]) and
|
||||
* drops this note from every child's [replyTo], so once this note leaves the
|
||||
* cache map nothing keeps the dead shell alive.
|
||||
*
|
||||
* This matters for the NIP-09 delete path: without severing the child →
|
||||
* parent `replyTo` links, the removed note leaks (held by each child) and a
|
||||
* later reply resolved through `computeReplyTo` would `getOrCreateNote` a
|
||||
* *second* Note for the same id — breaking the one-Note-per-id invariant.
|
||||
*
|
||||
* Returns the now-orphaned children (their other parents, if any, are kept).
|
||||
*/
|
||||
fun detachFromChildren(): List<Note> {
|
||||
val children = removeAllChildNotes()
|
||||
children.forEach { child ->
|
||||
val parents = child.replyTo
|
||||
if (parents != null && this in parents) {
|
||||
child.replyTo = parents - this
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
fun removeReaction(note: Note) {
|
||||
val tags = note.event?.tags ?: emptyArray()
|
||||
val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
|
||||
+48
@@ -162,4 +162,52 @@ class NotePruningReferenceTest {
|
||||
assertTrue(newer.labels["nostr"]?.contains(labelNote) == true, "label must move to the newer version")
|
||||
assertSame(newer, labelNote.replyTo?.single())
|
||||
}
|
||||
|
||||
// ── Fix 4: deleteNote severs child back-references (no partial deletion) ──
|
||||
|
||||
@Test
|
||||
fun detachFromChildrenSeversReplyToAndClearsCollections() {
|
||||
val parent = note("a1".repeat(32))
|
||||
val reply = note("b1".repeat(32)).apply { replyTo = listOf(parent) }
|
||||
parent.addReply(reply)
|
||||
|
||||
val detached = parent.detachFromChildren()
|
||||
|
||||
assertTrue(reply in detached, "the child must be returned as detached")
|
||||
assertTrue(parent.replies.isEmpty(), "parent must release its forward child references")
|
||||
assertTrue(
|
||||
reply.replyTo?.contains(parent) != true,
|
||||
"child must no longer point back at the removed parent",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detachFromChildrenKeepsOtherParents() {
|
||||
val deleted = note("a2".repeat(32))
|
||||
val survivor = note("c2".repeat(32))
|
||||
val reply = note("b2".repeat(32)).apply { replyTo = listOf(deleted, survivor) }
|
||||
deleted.addReply(reply)
|
||||
survivor.addReply(reply)
|
||||
|
||||
deleted.detachFromChildren()
|
||||
|
||||
assertEquals(listOf(survivor), reply.replyTo, "only the removed parent must be dropped from replyTo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detachFromChildrenSeversReactionAndZapSources() {
|
||||
val parent = note("a3".repeat(32))
|
||||
val reaction = sourceNote("31".repeat(32)).apply { replyTo = listOf(parent) }
|
||||
val zapSource = sourceNote("32".repeat(32)).apply { replyTo = listOf(parent) }
|
||||
|
||||
parent.addOnchainZap(zapSource, "tx1", claimedSats = 1L, verifiedSats = 1L, status = OnchainZapStatus.CONFIRMED)
|
||||
parent.addBoost(reaction)
|
||||
|
||||
parent.detachFromChildren()
|
||||
|
||||
assertTrue(parent.boosts.isEmpty())
|
||||
assertTrue(parent.onchainZaps.isEmpty())
|
||||
assertTrue(reaction.replyTo?.contains(parent) != true)
|
||||
assertTrue(zapSource.replyTo?.contains(parent) != true)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user