fix: re-issue a draft delete the server never received instead of assuming it landed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 14:16:11 +02:00
co-authored by Claude Opus 5
parent e226143f4c
commit 6fde8e9dce
2 changed files with 32 additions and 2 deletions
+5 -2
View File
@@ -407,8 +407,11 @@ class Entry<V> {
// copy of that edit, so the item opens on it. One the server *refused* is the other
// way round — the response is what won — and `adoptRow` drops it below.
const unsent = this.ports.conflicted(key) ? undefined : this.ports.pending(key)
const row = unsent === undefined ? res.draft : unsent
const draft = (row ?? undefined) as V | undefined
// A parked delete is what this tab wants gone, not what is there: the row is still the
// server's, so the reconcile at the end re-issues the delete instead of believing it
// landed and leaving the draft behind with nothing to remove it.
const row = unsent == null ? res.draft : unsent
const draft = (unsent === null ? undefined : row) as V | undefined
// The deployed side above always advances: the item did change, and a discard after
// the user keeps theirs has to land on what the server holds now.
if (!standOff) this.adoptRow(key, row, res.draftSavedAt, rowsAtRead)
+27
View File
@@ -1251,6 +1251,33 @@ describe('item store: conflicts', () => {
})
})
it('re-issues a draft delete the server never received, on reopening', async () => {
const rows = fakeRows()
const store = createItemStore(rows.port)
const key: ItemKey = { workspace: 'w', kind: 'resource', path: 'u/me/r' }
const draft = { ...deployedRes, description: 'a draft to be discarded' }
// The server keeps the draft throughout: the delete never reaches it.
const a = adapter(async () => ({ deployed: deployedRes, draft }))
const first = store.acquire(key, { workspace: 'w', path: 'u/me/r' }, a)
await settle()
rows.failing.add('u/me/r')
expect(await first.handle.discard()).toEqual({ removed: false })
expect(rows.sent.has('u/me/r')).toBe(false)
first.release()
const writesBefore = rows.writes.length
const second = store.acquire(key, { workspace: 'w', path: 'u/me/r' }, a)
await settle()
// Reopening must not read the parked delete as "there is no draft": the row is still the
// server's, so the delete has to go out again rather than be quietly forgotten.
expect(second.handle.value).toEqual(deployedRes)
expect(rows.writes.slice(writesBefore)).toEqual([{ path: 'u/me/r', value: null }])
rows.failing.delete('u/me/r')
await rows.port.flush(key)
expect(rows.sent.get('u/me/r')).toBeNull()
})
it('opens a conflicted item afresh, past the payload the server refused', async () => {
const rows = fakeRows()
const store = createItemStore(rows.port)