From aca3aef0ec7bb9b13be55db08993d451379e2d60 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 14 Sep 2026 18:36:44 +0200 Subject: [PATCH] fix: keep a draft written at a key while a save is moving onto it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015vn1jCHUcAdaG9C4NsUehF --- frontend/src/lib/itemStore.svelte.ts | 25 ++++++++++++++++++++++--- frontend/src/lib/itemStore.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index 402d02cd66..5f945a9258 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -757,6 +757,9 @@ export function createItemStore(ports: ItemRowPort) { const entries = new Map>() /** The latest save moving onto a key, by that key, and when its move is done. */ const moves = new Map; done: Promise }>() + /** Drafts written from outside at a key while a save is moving onto it: newer than that write, + * so the entry arriving there takes them rather than clearing the row it finds. */ + const arrivals = new Map() const internals: StoreInternals = { release(entry) { @@ -775,6 +778,11 @@ export function createItemStore(ports: ItemRowPort) { const displaced = entries.get(to) if (displaced && displaced !== entry) retire(displaced, entry) entries.set(to, entry) + const arrived = arrivals.get(to) + if (arrived !== undefined) { + arrivals.delete(to) + entry.applyExternal(arrived) + } }, /** * A save about to write the item at `key` and move onto it. It goes after the command @@ -804,7 +812,9 @@ export function createItemStore(ports: ItemRowPort) { turn: Promise.all(waits).then(() => (claimant.waitingOn = [])), release() { release() - if (moves.get(k) === move) moves.delete(k) + if (moves.get(k) !== move) return + moves.delete(k) + arrivals.delete(k) } } }, @@ -929,8 +939,14 @@ export function createItemStore(ports: ItemRowPort) { const bridge = { seed(workspace: string, kind: UserDraftItemKind, path: string, value: unknown): boolean { + if (value === undefined || value === null) return false const entry = find(workspace, kind, path) - if (!entry || value === undefined || value === null) return false + if (!entry) { + // Persisted as usual, and handed to the save moving onto this key when it lands. + const k = keyString({ workspace, kind: kind as ItemKind, path }) + if (moves.has(k)) arrivals.set(k, snapshot(value)) + return false + } entry.applyExternal(value) return true }, @@ -946,7 +962,10 @@ export function createItemStore(ports: ItemRowPort) { }, discard(workspace: string, kind: UserDraftItemKind, path: string): boolean { const entry = find(workspace, kind, path) - if (!entry) return false + if (!entry) { + arrivals.delete(keyString({ workspace, kind: kind as ItemKind, path })) + return false + } void entry.discard() return true }, diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index 40206d535f..6a8ee6cafa 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -530,6 +530,32 @@ describe('item store: one entry per key', () => { expect(rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: kept }) }) + it('keeps a draft written from outside at the key a save is moving onto', async () => { + const rows = fakeRows() + const store = createItemStore(rows.port) + const gate = deferred() + const b = { ...deployedRes, path: 'u/me/b' } + const temporary = newItemPath() + const { handle: moving } = store.acquire( + { workspace: 'w', kind: 'resource', path: temporary }, + { workspace: 'w', path: temporary, template: b }, + adapter({}, () => gate.promise) + ) + await settle() + moving.value = { ...b, args: { a: 2 } } + const moved = moving.save() + await settle() + const chat = { ...b, description: 'written by the chat meanwhile' } + // Nobody holds the key yet, so the caller persists it itself. + expect(store.bridge.seed('w', 'resource', 'u/me/b', chat)).toBe(false) + + gate.resolve() + expect(await moved).toMatchObject({ ok: true, moved: true }) + expect(moving.deployed).toEqual({ ...b, args: { a: 2 } }) + expect(moving.value).toEqual(chat) + expect(rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: chat }) + }) + it('keeps a change the draft comparison ignores in an editor a move replaces', async () => { type Sched = { path: string; summary: string; permissioned_as?: string } const rows = fakeRows()