fix: carry a discard's original snapshot through its recovery retry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 18:43:05 +02:00
co-authored by Claude Opus 5
parent 514685b65f
commit 03b62f46db
2 changed files with 50 additions and 6 deletions
+13 -6
View File
@@ -481,13 +481,15 @@ class Entry<V> {
* was asked to throw away. `absent`: never had the item, so the row is the caller's.
* `failed`: it has the item, on a baseline it cannot refresh, and still shows the draft. */
async discarded(): Promise<'done' | 'absent' | 'failed'> {
await this.discard()
const asked = this.asOf()
await this.discard(asked)
if (this.retired || !this.loaded) return 'absent'
if (!this.stale) return 'done'
// Stale refused it. A reload is what clears that, and with a baseline it can trust the
// discard means something again — so the one thing worth trying before giving up.
// discard means something again — so the one thing worth trying before giving up. The
// retry carries the state as of the request, so an edit typed during that reload stands.
if (!this.adapter || !(await this.load(this.adapter)).ok) return 'failed'
await this.discard()
await this.discard(asked)
return this.stale ? 'failed' : 'done'
}
@@ -615,7 +617,10 @@ class Entry<V> {
if (changed) this.replaceValue(next as V)
}
discard(): Promise<DiscardOutcome> {
/** `asked`: for a caller retrying after recovering the item, the state as of the *first*
* attempt. Taking a fresh one would let an edit made during that recovery look like part of
* what the discard was asked to throw away. */
discard(asked?: AsOf): Promise<DiscardOutcome> {
return this.run(async (at) => {
if (!this.loaded || this.retired) return { removed: false }
// Reverting to a baseline known to be behind the server would write it back.
@@ -655,7 +660,7 @@ class Entry<V> {
this.reconcile()
await this.settleRows([this.key])
return { removed: false }
})
}, asked)
}
/**
@@ -831,7 +836,9 @@ export type ItemHandle<V> = {
readonly pristine: boolean
readonly revision: number
readonly meta: unknown
/** `dirty && valid && writable && !busy`. */
/** `dirty && valid && writable && !busy`, and not stale: a write landed on this item
* elsewhere and the re-read meant to pick it up failed, so saving would put a baseline known
* to be behind the server back over it. A reload clears that. */
readonly canSave: boolean
save(): Promise<SaveOutcome>
discard(): Promise<DiscardOutcome>
+37
View File
@@ -1386,6 +1386,43 @@ describe('item store: conflicts', () => {
expect(item.status).not.toBe('conflicted')
})
it('keeps an edit typed while a stale discard was recovering the item', async () => {
const rows = fakeRows()
const store = createItemStore(rows.port)
const recovery = deferred<ItemLoad<Res>>()
let reads = 0
const key: ItemKey = { workspace: 'w', kind: 'resource', path: 'u/me/r' }
const { handle: item } = store.acquire(
key,
{ workspace: 'w', path: 'u/me/r' },
adapter(() => {
reads++
if (reads === 1) return Promise.resolve({ deployed: deployedRes })
if (reads === 2) return Promise.reject(new Error('the GET failed'))
return recovery.promise
})
)
await settle()
item.value = { ...deployedRes, description: 'mine' }
// A write landed elsewhere and the re-read for it failed, so this editor is stale.
expect(await store.bridge.refresh('w', 'resource', 'u/me/r')).toBe('failed')
// The chat discards. That is refused while stale, so it reloads to recover — and the
// user types while that GET is out.
const discarding = store.bridge.discard('w', 'resource', 'u/me/r')
await settle()
item.value = { ...deployedRes, description: 'typed during the recovery' }
recovery.resolve({ deployed: deployedRes })
// The retry is about the value the chat named, not this one, so the edit stands.
expect(await discarding).toBe('done')
expect(item.value?.description).toBe('typed during the recovery')
expect(rows.writes.at(-1)).toEqual({
path: 'u/me/r',
value: { ...deployedRes, description: 'typed during the recovery' }
})
})
it('stops an editor acting on a baseline its failed re-read left behind', async () => {
const rows = fakeRows()
const store = createItemStore(rows.port)