fix: give a refused patch back field by field, not all or nothing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 17:11:30 +02:00
co-authored by Claude Opus 5
parent f8ebd281c8
commit 8f027091eb
2 changed files with 36 additions and 7 deletions
+8 -7
View File
@@ -716,17 +716,18 @@ class Entry<V> {
this.serverDeployed = { ...this.serverDeployed, ...sent } as V
}
release()
// The value is the user's copy, so it is given back by hand — to the baseline as it
// stands now, which is the server's value or a later patch's, never this one's. Not
// given back at all if something has written the value since: that is newer than this
// patch and rolling it back would be this patch overwriting it.
// The value is the user's copy, so it is given back by hand, field by field, to the
// baseline as it stands now — the server's value or a later patch's, never this one's.
// Only a field still carrying what this patch put there, so an edit made meanwhile to
// any other field is left alone. And none of them once an outside write has landed:
// that wrote the value wholesale and is newer than this patch, and a field of it
// happening to match what this patch sent is not this patch's to take back.
if (failed) {
const base = (onTemplate ? this.template : this.deployed) as
| Record<string, unknown>
| undefined
const moved =
this.edits !== appliedAt.edits || this.externals !== appliedAt.externals
if (base !== undefined && this.value !== undefined && !moved) {
const written = this.externals !== appliedAt.externals
if (base !== undefined && this.value !== undefined && !written) {
const out = snapshot(this.value) as Record<string, unknown>
let changed = false
for (const [k, v] of Object.entries(sent)) {
+28
View File
@@ -843,6 +843,34 @@ describe('item store: origins', () => {
expect(item.dirty).toBe(true)
})
it('gives a refused toggle its field back while an edit made meanwhile stays', async () => {
type Sched = { path: string; enabled: boolean; summary: string }
const rows = fakeRows()
const store = createItemStore(rows.port)
const refusal = deferred()
const { handle: item } = store.acquire(
{ workspace: 'w', kind: 'trigger_schedule', path: 's' },
{ workspace: 'w', path: 's' },
{
load: async () => ({ deployed: { path: 's', enabled: true, summary: 'a' } })
} as ItemAdapter<Sched>
)
await settle()
const toggling = item.patch({ enabled: false }, () => refusal.promise)
// The user types in another field while the request is out.
item.value = { path: 's', enabled: false, summary: 'typed while disabling' }
refusal.reject(new Error('refused'))
expect(await toggling).toMatchObject({ ok: false })
// The server stayed enabled, so showing it disabled — and drafting that — would be an
// editor claiming a schedule is off while it is still running. The summary is untouched.
expect(item.value).toEqual({ path: 's', enabled: true, summary: 'typed while disabling' })
expect(rows.writes.at(-1)).toEqual({
path: 's',
value: { path: 's', enabled: true, summary: 'typed while disabling' }
})
})
it('does not roll a refused toggle back over a draft written while it was out', async () => {
type Sched = { path: string; enabled: boolean; summary: string }
const rows = fakeRows()