From 8f027091eb625b0d2707ff8a9b8128fe5068e90b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 15 Sep 2026 17:11:30 +0200 Subject: [PATCH] fix: give a refused patch back field by field, not all or nothing Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/itemStore.svelte.ts | 15 ++++++++------- frontend/src/lib/itemStore.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index c5d3c57175..7e96a68282 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -716,17 +716,18 @@ class Entry { 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 | 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 let changed = false for (const [k, v] of Object.entries(sent)) { diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index 247c05fa61..3106d9b751 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -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 + ) + 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()