fix(desktop): preserve rebased draft deletion retries

A stale tombstone completion could clear the durable intent for a newer rebased delete, allowing a deleted draft to be resurrected after the newer publish fails.

Only let a tombstone completion consume deletion state when it still dominates the current remote head.

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 13:28:35 -04:00
co-authored by Will Pfleger
parent 4244c63425
commit ca85fbf620
2 changed files with 85 additions and 5 deletions
@@ -383,10 +383,12 @@ test("test_unuploaded_attachment_cancels_stale_text_publish", async () => {
function deferred() {
let resolve;
const promise = new Promise((complete) => {
let reject;
const promise = new Promise((complete, fail) => {
resolve = complete;
reject = fail;
});
return { promise, resolve };
return { promise, resolve, reject };
}
test("test_newer_edit_during_inflight_publish_survives", async () => {
@@ -469,3 +471,77 @@ test("test_deletion_during_inflight_publish_wins", async () => {
assert.ok(rebasedTombstone);
assert.equal(loadDraftEntry(channelA), undefined);
});
test("test_stale_tombstone_completion_preserves_rebased_delete", async () => {
setup();
const published = [];
const draftPublish = deferred();
const staleTombstone = deferred();
const rebasedTombstone = deferred();
const rebasedTombstoneStarted = deferred();
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.content === "" && published.length === 1
? (input.createdAt ?? 0) - 1
: (input.createdAt ?? 0),
kind: input.kind,
pubkey,
content: input.content,
sig: "",
tags: input.tags,
}),
publishEvent: async (event) => {
const publishIndex = published.push(event);
if (publishIndex === 1) await draftPublish.promise;
if (publishIndex === 2) await staleTombstone.promise;
if (publishIndex === 3) {
rebasedTombstoneStarted.resolve();
await rebasedTombstone.promise;
}
},
});
const local = draft(channelA, "draft to delete");
saveDraftEntry(channelA, local);
manager.queuePublish(channelA, local);
const flush = manager.flushPublishes();
while (published.length === 0) await Promise.resolve();
const deletion = manager.queueDeletion(channelA, channelA);
removeRemoteDraftEntry(channelA);
while (published.length < 2) await Promise.resolve();
draftPublish.resolve();
const draftEvent = published[0];
staleTombstone.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
await rebasedTombstoneStarted.promise;
rebasedTombstone.reject(new Error("offline"));
await flush;
await deletion;
const retried = [];
const retryManager = new DraftSyncManager(pubkey, "wss://relay.example", {
deriveAddress: async () => "address-a",
publishEvent: async (event) => retried.push(event),
sign: async (input) => ({
id: "retry",
created_at: input.createdAt ?? 0,
kind: input.kind,
pubkey,
content: input.content,
sig: "",
tags: input.tags,
}),
});
retryManager.start();
await Promise.resolve();
await retryManager.destroy();
await manager.destroy();
assert.ok(retried.some((event) => event.content === ""));
assert.ok(retried.every((event) => event.created_at > draftEvent.created_at));
});
@@ -327,9 +327,13 @@ export class DraftSyncManager {
"Timed out deleting draft.",
"Failed to delete draft.",
);
if (state.pendingDeletion?.address === pending.address) {
state.pendingDeletion = undefined;
state.remoteHead = event;
const current = this.state.get(pending.address) ?? state;
if (
current.pendingDeletion?.address === pending.address &&
(!current.remoteHead || compareHeads(event, current.remoteHead) >= 0)
) {
current.pendingDeletion = undefined;
current.remoteHead = event;
this.writeSidecar();
}
} catch (error) {