refactor: stamp every ask of an item so the one asked last holds

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vn1jCHUcAdaG9C4NsUehF
This commit is contained in:
Ruben Fiszel
2026-09-14 20:27:04 +02:00
co-authored by Claude Opus 5
parent 8d0966d902
commit 6e98542839
2 changed files with 37 additions and 17 deletions
+28 -17
View File
@@ -138,6 +138,11 @@ function errorMessage(e: unknown): string {
* item's revision for another's. */
let nextRevision = 1
/** Stamps every write and discard asked of an item, so the one asked last can be told from the
* one that reaches it last: a draft parked for a save moving onto a key arrives after asks the
* entry there has already recorded. */
let nextAsk = 1
const superseded = { ok: false, error: 'Another save of this item replaced this one' } as const
class Entry<V> {
@@ -180,10 +185,10 @@ class Entry<V> {
/** Replaced by an entry that moved onto its key: it no longer owns the row there, and a write
* reaching its turn does nothing, as its handles show the item that replaced it. */
retired = false
/** Discards asked and still waiting for their turn, and which of a write and a discard was
* asked for this item last: the later of the two is the one that holds. */
/** Discards asked and still waiting for their turn, and the last ask made of this item, stamped
* when it was made: of a write and a discard, the one asked later is the one that holds. */
discardsAsked = 0
lastOutsideAsk: 'write' | 'discard' | undefined
lastAsk: { kind: 'write' | 'discard'; at: number } | undefined
/** The command running now, past its turn: what a move onto this key waits for. */
running: Promise<unknown> | undefined
/** The entries a save of this entry waits for before moving: a move skips waiting for any
@@ -235,6 +240,12 @@ class Entry<V> {
this.reconcile()
}
/** Keep the latest of the asks made of this item, by when each was asked. */
private recordAsk(kind: 'write' | 'discard', at: number): void {
if (at < (this.lastAsk?.at ?? 0)) return
this.lastAsk = { kind, at }
}
/** Mirror `dirty ? value : null` to the draft row. The only place a row is written. */
reconcile(): void {
const key = this.key
@@ -346,10 +357,11 @@ class Entry<V> {
}
/** An outside write (the AI chat, another editor): a real divergence, never settling. */
applyExternal(value: V): number {
/** `askedAt`: when the write was made, for one parked while a move was heading here. */
applyExternal(value: V, askedAt = nextAsk++): number {
// Before the unchanged-value return: re-writing the same draft is still an ask, and which
// ask came last is what outranks a discard still waiting for its turn.
this.lastOutsideAsk = 'write'
this.recordAsk('write', askedAt)
if (this.loaded && serialize(value) === serialize(this.value)) return this.revision
this.pristine = false
this.removed = false
@@ -431,7 +443,7 @@ class Entry<V> {
// A discard asked before the move landed wins over the edits it was asked to drop; had it
// run first, it would have dropped anything typed after it too.
if (old.value === undefined || this.value === undefined) return
if (old.discardsAsked > 0 && old.lastOutsideAsk !== 'write') return
if (old.discardsAsked > 0 && old.lastAsk?.kind !== 'write') return
// Not loaded yet (its read waits behind the move): only an outside write can have filled it,
// and nothing has persisted that write but this entry.
if (!old.loaded) {
@@ -471,12 +483,12 @@ class Entry<V> {
discard(): Promise<DiscardOutcome> {
this.discardsAsked++
this.lastOutsideAsk = 'discard'
this.recordAsk('discard', nextAsk++)
return this.run(async () => {
this.discardsAsked--
// A draft written again after this discard was asked outranks it, however the move it
// waited for turned out: the ask that came last is the one that holds.
if (this.lastOutsideAsk === 'write') return { removed: false }
if (this.lastAsk?.kind === 'write') return { removed: false }
if (!this.loaded || this.retired) return { removed: false }
if (this.origin === 'draft') {
const kept = snapshot(this.value)
@@ -779,9 +791,10 @@ export function createItemStore(ports: ItemRowPort) {
const entries = new Map<string, Entry<any>>()
/** The latest save moving onto a key, by that key, and when its move is done. */
const moves = new Map<string, { owner: Entry<any>; done: Promise<void> }>()
/** 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<string, unknown>()
/** Drafts written from outside at a key while a save is moving onto it, stamped when they were
* written: newer than that write, so the entry arriving there takes them rather than clearing
* the row it finds, unless it has been asked something later still. */
const arrivals = new Map<string, { value: unknown; at: number }>()
const internals: StoreInternals = {
release(entry) {
@@ -803,11 +816,9 @@ export function createItemStore(ports: ItemRowPort) {
const arrived = arrivals.get(to)
if (arrived !== undefined) {
arrivals.delete(to)
// Parked before anything this entry has been asked since, so applying it now does not
// make it the latest ask: a discard asked meanwhile still outranks it.
const asked = entry.lastOutsideAsk
entry.applyExternal(arrived)
entry.lastOutsideAsk = asked
// It was asked when it was parked, not now: whichever of it and the asks this entry
// recorded meanwhile came last is the one that holds.
entry.applyExternal(arrived.value, arrived.at)
}
},
/**
@@ -970,7 +981,7 @@ export function createItemStore(ports: ItemRowPort) {
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))
if (moves.has(k)) arrivals.set(k, { value: snapshot(value), at: nextAsk++ })
return false
}
entry.applyExternal(value)
+9
View File
@@ -701,6 +701,15 @@ describe('item store: one entry per key', () => {
expect(discardedAfterParking.moving.dirty).toBe(false)
expect(discardedAfterParking.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: null })
// The other way round: Discard first, then a draft parked for the move. The parked draft was
// asked last, so it is what the item arrives with.
const parkedAfterDiscard = await moveOnto(false, (store, _open, moving) => {
void moving.discard()
store.bridge.seed('w', 'resource', 'u/me/b', again)
})
expect(parkedAfterDiscard.moving.value).toEqual(again)
expect(parkedAfterDiscard.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: again })
// Deleted last: the delete is what holds, however many drafts preceded it.
const deleted = await moveOnto(true, (store) => {
store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'first draft' })