fix: keep the row baseline a read that started earlier would rewind

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 11:01:36 +02:00
co-authored by Claude Opus 5
parent 899899b284
commit b33f70e77f
2 changed files with 47 additions and 3 deletions
+10 -2
View File
@@ -319,6 +319,9 @@ class Entry<V> {
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<V>
try {
if (this.retired) return superseded
@@ -344,8 +347,13 @@ class Entry<V> {
}
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
+37 -1
View File
@@ -32,6 +32,8 @@ function fakeRows() {
const hints = new Map<string, boolean>()
/** 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<string, unknown>()
const sent = new Map<string, unknown>()
@@ -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>) => 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<ItemLoad<Res>>()
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', {})