fix(desktop): preserve in-flight draft sync intents

Avoid letting an older asynchronous publish erase a newer edit or leave a concurrent deletion behind it. Rebase any raced tombstone after the accepted draft event.

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 12:42:37 -04:00
co-authored by Will Pfleger
parent a5126fe3ce
commit 4244c63425
2 changed files with 118 additions and 3 deletions
@@ -24,6 +24,7 @@ import {
clearAllDrafts,
initDraftStore,
loadDraftEntry,
removeRemoteDraftEntry,
saveDraftEntry,
} from "./useDrafts.ts";
@@ -379,3 +380,92 @@ test("test_unuploaded_attachment_cancels_stale_text_publish", async () => {
assert.equal(published.length, 0);
});
function deferred() {
let resolve;
const promise = new Promise((complete) => {
resolve = complete;
});
return { promise, resolve };
}
test("test_newer_edit_during_inflight_publish_survives", async () => {
setup();
const published = [];
const firstPublish = 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.createdAt ?? 0,
kind: input.kind,
pubkey,
content: input.content,
sig: "",
tags: input.tags,
}),
publishEvent: async (event) => {
published.push(event);
if (published.length === 1) await firstPublish.promise;
},
});
manager.queuePublish(channelA, draft(channelA, "older edit"));
const flush = manager.flushPublishes();
while (published.length === 0) await Promise.resolve();
manager.queuePublish(channelA, draft(channelA, "newer edit"));
firstPublish.resolve();
await flush;
await manager.destroy();
assert.equal(published.length, 2);
assert.match(published[1].content, /newer edit/);
});
test("test_deletion_during_inflight_publish_wins", async () => {
setup();
const published = [];
const firstPublish = deferred();
const tombstonePublished = 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.createdAt ?? 0,
kind: input.kind,
pubkey,
content: input.content,
sig: "",
tags: input.tags,
}),
publishEvent: async (event) => {
published.push(event);
if (published.length === 1) await firstPublish.promise;
if (event.content === "") tombstonePublished.resolve();
},
});
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);
await tombstonePublished.promise;
removeRemoteDraftEntry(channelA);
firstPublish.resolve();
await flush;
await deletion;
await manager.destroy();
const draftEvent = published.find((event) => event.content !== "");
const rebasedTombstone = published.find(
(event) => event.content === "" && event.created_at > draftEvent.created_at,
);
assert.ok(rebasedTombstone);
assert.equal(loadDraftEntry(channelA), undefined);
});
+28 -3
View File
@@ -244,6 +244,10 @@ export class DraftSyncManager {
limit: 1,
});
state = this.state.get(address) ?? state;
if (state.pendingDeletion) {
this.reschedulePublishes();
return;
}
if (state.remoteHead?.content === "") {
state.pendingPublish = undefined;
removeRemoteDraftEntry(pending.draftKey);
@@ -271,9 +275,30 @@ export class DraftSyncManager {
"Timed out publishing draft.",
"Failed to publish draft.",
);
state.base = event;
state.remoteHead = event;
state.pendingPublish = undefined;
const current = this.state.get(address) ?? state;
const deletionWon =
current.pendingDeletion !== undefined ||
current.remoteHead?.content === "";
if (!current.remoteHead || compareHeads(event, current.remoteHead) >= 0) {
current.base = event;
current.remoteHead = event;
}
if (current.pendingPublish === pending)
current.pendingPublish = undefined;
if (deletionWon) {
const deletion =
current.pendingDeletion ??
({
draftKey: pending.draftKey,
channelId: pending.channelId,
address,
} satisfies PendingDeletion);
deletion.base = event;
current.pendingDeletion = deletion;
this.writeSidecar();
await this.publishTombstone(deletion);
}
this.reschedulePublishes();
} catch (error) {
console.warn("[draftSync] draft publish failed:", error);
}