diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index 64bf32ae5c..bc2652bab1 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -481,13 +481,15 @@ class Entry { * 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 { if (changed) this.replaceValue(next as V) } - discard(): Promise { + /** `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 { 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 { this.reconcile() await this.settleRows([this.key]) return { removed: false } - }) + }, asked) } /** @@ -831,7 +836,9 @@ export type ItemHandle = { 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 discard(): Promise diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index a1b19d8d71..dca6531139 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -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>() + 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)