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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-13 14:00:09 -04:00
co-authored by Will Pfleger
parent ca85fbf620
commit 325ff603ed
2 changed files with 67 additions and 4 deletions
@@ -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);
});
+14 -4
View File
@@ -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) {