fix: keep an edit this read's own retry got refused

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 22:24:44 +02:00
co-authored by Claude Opus 5
parent 112d95ba5e
commit d902638c71
2 changed files with 41 additions and 1 deletions
+7 -1
View File
@@ -412,6 +412,7 @@ class Entry<V> {
let rowsAtRead = 0
let valuesAtRead = 0
let unbasedPending = false
let refusedBefore = false
let waitingBefore: unknown | undefined
let res: ItemLoad<V>
try {
@@ -423,6 +424,8 @@ class Entry<V> {
// would go out unconditional and take over a row created since. The read below
// gives it one, and `pending` keeps showing it meanwhile.
unbasedPending = this.ports.unbased(key)
// Whether the server had already refused this key before this read went near it.
refusedBefore = this.ports.conflicted(key)
if (!unbasedPending) await this.ports.flush(key)
// Taken here, not when the command was asked for: rows this read's own flush sent
// are in the response it is about to get. Only one handed over from now on, while
@@ -472,7 +475,10 @@ class Entry<V> {
// A row the syncer took but could not send is newer than this response and is the only
// 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 : waitingBefore
// A payload the server refused *before* this read is stale: the response is what won.
// One refused by this read's own retry is not — it is the user's unsent edit, and the
// only copy of it, so it stays on screen with the conflict for them to settle.
const unsent = refusedBefore ? undefined : waitingBefore
// 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.
+34
View File
@@ -2051,6 +2051,40 @@ describe('item store: conflicts', () => {
expect(rows.sent.get('u/me/r')).toBeNull()
})
it('keeps the failed edit its own reopening retry got refused', async () => {
const rows = fakeRows()
const store = createItemStore(rows.port)
const key: ItemKey = { workspace: 'w', kind: 'resource', path: 'u/me/r' }
const a = adapter(async () => ({
deployed: deployedRes,
draft: rows.sent.get('u/me/r') as Res | undefined,
draftSavedAt: 'T1'
}))
// A draft exists, so this key has a baseline and its autosave is not unconditional.
rows.sent.set('u/me/r', { ...deployedRes, description: 'the first draft' })
const first = store.acquire(key, { workspace: 'w', path: 'u/me/r' }, a)
await settle()
rows.failing.add('u/me/r')
const mine = { ...deployedRes, description: 'mine, never sent' }
first.handle.value = mine
await rows.port.flush(key)
first.release()
// Another tab writes a newer draft, and this tab's network recovers. Reopening retries
// the parked edit before reading, and that retry is what the server refuses.
rows.sent.set('u/me/r', { ...deployedRes, description: 'the other tab' })
rows.failing.delete('u/me/r')
rows.conflictOnFlush.add('u/me/r')
const second = store.acquire(key, { workspace: 'w', path: 'u/me/r' }, a)
await settle()
// That edit is the only copy anyone has. Dropping it for the remote draft and clearing
// the conflict on the way past would lose it with nothing said.
expect(second.handle.value).toEqual(mine)
expect(second.handle.status).toBe('conflicted')
})
it('opens a conflicted item afresh, past the payload the server refused', async () => {
const rows = fakeRows()
const store = createItemStore(rows.port)