From b33f70e77fb4ff61efac73e91da474299fcf4afe Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 15 Sep 2026 11:01:36 +0200 Subject: [PATCH] fix: keep the row baseline a read that started earlier would rewind Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/itemStore.svelte.ts | 12 +++++++-- frontend/src/lib/itemStore.test.ts | 38 +++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index b9ba43dcd0..41dfe7d403 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -319,6 +319,9 @@ class Entry { const editsAtStart = editedBefore ?? this.edits return this.run(async () => { const key = this.key + // Whether anything could have written the row while this read was in flight: only an + // item already on screen has a row this entry is keeping. + const held = this.loaded let res: ItemLoad try { if (this.retired) return superseded @@ -344,8 +347,13 @@ class Entry { } this.deployed = this.withPatched(snapshot(res.deployed)) this.origin = res.deployed !== undefined ? 'deployed' : 'draft' - this.row = serialize(res.draft) ?? null - this.ports.seedSync(key, res.draftSavedAt) + // A row written while this read was in flight is newer than the one it read, and the + // syncer has already sent it: reseeding from this response would rewind `last_sync` + // past that, and every write after it is refused as a conflict. + if (!(held && keepValue)) { + this.row = serialize(res.draft) ?? null + this.ports.seedSync(key, res.draftSavedAt) + } this.loaded = true if (!keepValue) { this.pristine = this.settles && this.origin === 'deployed' && res.draft === undefined diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index c9555c3bf7..78e941a08f 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -32,6 +32,8 @@ function fakeRows() { const hints = new Map() /** Paths whose parked payload was dropped. */ const dropped: string[] = [] + /** Every `last_sync` baseline the syncer was given, in order. */ + const seeds: { path: string; at: string | undefined }[] = [] /** What a write leaves queued, and what a flush sends: the syncer debounces. */ const queued = new Map() const sent = new Map() @@ -55,7 +57,7 @@ function fakeRows() { conflicts.delete(key.path) writes.push({ path: key.path, value }) }, - seedSync: () => {}, + seedSync: (key, at) => void seeds.push({ path: key.path, at }), conflicted: (key) => conflicts.has(key.path), failure: (key) => failures.get(key.path), dropPending: (key) => void dropped.push(key.path), @@ -68,6 +70,7 @@ function fakeRows() { failing, hints, dropped, + seeds, sent, hold: (until: Promise) => void (holdFlush = until) } @@ -458,6 +461,39 @@ describe('item store: outside writes', () => { ]) }) + it('does not rewind the row baseline to what a read that started earlier saw', async () => { + const rows = fakeRows() + const read = deferred>() + let reads = 0 + const { item } = await open( + rows, + adapter(() => { + reads++ + return reads > 1 + ? read.promise + : Promise.resolve({ deployed: deployedRes, draftSavedAt: 'T1' }) + }) + ) + expect(rows.seeds).toEqual([{ path: 'u/me/r', at: 'T1' }]) + + const reloading = item.reload() + await settle() + // The chat writes while the read is out; its row goes, and the syncer moves past T1. + item.applyExternal({ ...deployedRes, description: 'from the chat' }) + read.resolve({ deployed: deployedRes, draft: deployedRes, draftSavedAt: 'T1' }) + await reloading + + expect(item.value?.description).toBe('from the chat') + // Reseeding T1 here would put the syncer behind the row it has already sent, and the + // server refuses everything after that as a conflict. + expect(rows.seeds).toEqual([{ path: 'u/me/r', at: 'T1' }]) + expect(item.status).not.toBe('conflicted') + expect(rows.writes.at(-1)).toEqual({ + path: 'u/me/r', + value: { ...deployedRes, description: 'from the chat' } + }) + }) + it('applies outside writes in order, each one a new revision and never settling', async () => { const rows = fakeRows() const { item } = await open(rows, adapter({ deployed: deployedRes }), 'u/me/r', {})