From 325ff603edd9be5a192c961f8bf7a3016fc85496 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 13 Jul 2026 14:00:09 -0400 Subject: [PATCH] fix(desktop): release remote-tombstone draft sync A remote tombstone that already won a raced publish could leave a synthetic deletion intent durable forever and block future edits. Complete a deletion based on the winning head, and let an explicit later edit carry a known tombstone base so it can supersede that delete. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/messages/lib/draftSync.test.mjs | 53 +++++++++++++++++++ .../src/features/messages/lib/draftSync.ts | 18 +++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/messages/lib/draftSync.test.mjs b/desktop/src/features/messages/lib/draftSync.test.mjs index dbf9de571..76d2f4fb6 100644 --- a/desktop/src/features/messages/lib/draftSync.test.mjs +++ b/desktop/src/features/messages/lib/draftSync.test.mjs @@ -545,3 +545,56 @@ test("test_stale_tombstone_completion_preserves_rebased_delete", async () => { assert.ok(retried.some((event) => event.content === "")); assert.ok(retried.every((event) => event.created_at > draftEvent.created_at)); }); + +test("test_remote_tombstone_during_publish_allows_later_draft", async () => { + setup(); + const published = []; + const draftPublish = deferred(); + const remoteTombstone = wrapped({ + id: "remote-tombstone", + createdAt: Math.floor(Date.now() / 1_000) + 100, + address: "address-a", + channelId: channelA, + content: "", + }); + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + deriveAddress: async () => "address-a", + encrypt: async (content) => content, + fetchEvents: async () => [], + sign: async (input) => ({ + id: `signed-${published.length}`, + created_at: input.createdAt ?? 0, + kind: input.kind, + pubkey, + content: input.content, + sig: "", + tags: input.tags, + }), + publishEvent: async (event) => { + published.push(event); + if (published.length === 1) await draftPublish.promise; + }, + }); + + manager.queuePublish(channelA, draft(channelA, "raced draft")); + const flush = manager.flushPublishes(); + while (published.length === 0) await Promise.resolve(); + await manager.mergeEvent(remoteTombstone); + draftPublish.resolve(); + await flush; + + assert.equal( + localStorage.getItem(`buzz-draft-sync.v1:wss://relay.example:${pubkey}`), + "{}", + ); + + manager.queuePublish(channelA, draft(channelA, "new draft")); + await manager.flushPublishes(); + await manager.destroy(); + + const newDraft = published.find((event) => + event.content.includes("new draft"), + ); + assert.ok(newDraft); + assert.ok(newDraft.created_at > remoteTombstone.created_at); +}); diff --git a/desktop/src/features/messages/lib/draftSync.ts b/desktop/src/features/messages/lib/draftSync.ts index 6de775d49..8041e6b6e 100644 --- a/desktop/src/features/messages/lib/draftSync.ts +++ b/desktop/src/features/messages/lib/draftSync.ts @@ -33,6 +33,7 @@ type PendingPublish = { draft: DraftState; channelId: string; address?: string; + base?: RemoteHead; }; type PendingDeletion = { draftKey: string; @@ -121,7 +122,12 @@ export class DraftSyncManager { return; } entry.draftKey = draftKey; - entry.pendingPublish = { draftKey, draft, channelId: draft.channelId }; + entry.pendingPublish = { + draftKey, + draft, + channelId: draft.channelId, + base: entry.remoteHead?.content === "" ? entry.remoteHead : undefined, + }; this.state.set(draftKey, entry); // Resolve the opaque address while the draft is alive so normal delete // paths can durably record it before removing visible local state. @@ -248,7 +254,7 @@ export class DraftSyncManager { this.reschedulePublishes(); return; } - if (state.remoteHead?.content === "") { + if (state.remoteHead?.content === "" && !pending.base) { state.pendingPublish = undefined; removeRemoteDraftEntry(pending.draftKey); this.reschedulePublishes(); @@ -328,12 +334,16 @@ export class DraftSyncManager { "Failed to delete draft.", ); const current = this.state.get(pending.address) ?? state; + const winning = + !current.remoteHead || compareHeads(event, current.remoteHead) >= 0 + ? event + : current.remoteHead; if ( current.pendingDeletion?.address === pending.address && - (!current.remoteHead || compareHeads(event, current.remoteHead) >= 0) + winning.content === "" ) { current.pendingDeletion = undefined; - current.remoteHead = event; + current.remoteHead = winning; this.writeSidecar(); } } catch (error) {