mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
a5126fe3ce
commit
4244c63425
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user