fix: hold a discard superseded by a later draft, whether or not the move lands

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vn1jCHUcAdaG9C4NsUehF
This commit is contained in:
Ruben Fiszel
2026-09-14 20:00:03 +02:00
co-authored by Claude Opus 5
parent 226cf97d1f
commit 4ea45a83aa
2 changed files with 31 additions and 7 deletions
+3
View File
@@ -474,6 +474,9 @@ class Entry<V> {
this.lastOutsideAsk = 'discard'
return this.run(async () => {
this.discardsAsked--
// A draft written again after this discard was asked outranks it, however the move it
// waited for turned out: the ask that came last is the one that holds.
if (this.lastOutsideAsk === 'write') return { removed: false }
if (!this.loaded || this.retired) return { removed: false }
if (this.origin === 'draft') {
const kept = snapshot(this.value)
+28 -7
View File
@@ -620,7 +620,8 @@ describe('item store: one entry per key', () => {
const b = { ...deployedRes, path: 'u/me/b' }
const moveOnto = async (
openBefore: boolean,
outside: (store: ReturnType<typeof createItemStore>, open: () => void) => void
outside: (store: ReturnType<typeof createItemStore>, open: () => void) => void,
refuse?: string
) => {
const rows = fakeRows()
const store = createItemStore(rows.port)
@@ -629,23 +630,30 @@ describe('item store: one entry per key', () => {
const { handle: moving } = store.acquire(
{ workspace: 'w', kind: 'resource', path: temporary },
{ workspace: 'w', path: temporary, template: b },
adapter({}, () => gate.promise)
adapter({}, async () => {
await gate.promise
if (refuse) throw new Error(refuse)
})
)
await settle()
moving.value = { ...b, args: { a: 2 } }
const moved = moving.save()
await settle()
const open = () =>
store.acquire(
let opened: ItemHandle<Res> | undefined
const open = () => {
opened = store.acquire(
{ workspace: 'w', kind: 'resource', path: 'u/me/b' },
{ workspace: 'w', path: 'u/me/b' },
adapter({ deployed: b })
)
).handle
}
if (openBefore) open()
outside(store, open)
gate.resolve()
expect(await moved).toMatchObject({ ok: true, moved: true })
return { moving, rows }
expect(await moved).toMatchObject(refuse ? { ok: false } : { ok: true, moved: true })
await settle()
// A refused move leaves the item where it was, with the editor that opened on it.
return { moving, shown: refuse ? opened! : moving, rows }
}
// Deleted, then written again: the newer draft is the one that survives the move.
const again = { ...b, description: 'written again after the delete' }
@@ -666,6 +674,19 @@ describe('item store: one entry per key', () => {
expect(same.moving.value).toEqual(again)
expect(same.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: again })
// The move failing changes none of that: the last ask is still the one that holds.
const failed = await moveOnto(
true,
(store) => {
store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'first draft' })
store.bridge.discard('w', 'resource', 'u/me/b')
store.bridge.seed('w', 'resource', 'u/me/b', again)
},
'refused by the server'
)
expect(failed.shown.value).toEqual(again)
expect(failed.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: again })
// Deleted last: the delete is what holds, however many drafts preceded it.
const deleted = await moveOnto(true, (store) => {
store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'first draft' })