fix: a failed anchor read is not a raced deploy, and take latest closes only its own drawer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 18:20:06 +02:00
co-authored by Claude Opus 5
parent 2d0a4f777f
commit 1662dfbc33
5 changed files with 36 additions and 21 deletions
@@ -198,9 +198,13 @@
async function takeLatest() {
if (!data || data.mode !== 'normal' || !data.onTakeLatest || takingLatest) return
takingLatest = true
// Persisting the base is awaited, and this drawer outlives the editor that filled
// it: close only the opening this action belongs to, or it takes down whichever
// diff was opened meanwhile.
const opening = openingToken
try {
await data.onTakeLatest(headShown)
diffViewer?.closeDrawer()
if (opening === openingToken) diffViewer?.closeDrawer()
} finally {
takingLatest = false
}
@@ -369,10 +369,12 @@
policy = await updatePolicy($app, policy)
// Read the head this deploy is about to append to, so the entry it writes can be
// told from one landing beside it (see versionThisDeployWrote).
const headBefore = await AppService.getAppLatestVersion({
workspace: $workspaceStore!,
path: $appPath
}).catch(() => undefined)
const anchor = (
await AppService.getAppLatestVersion({
workspace: $workspaceStore!,
path: $appPath
}).catch(() => undefined)
)?.version
await AppService.updateApp({
workspace: $workspaceStore!,
path: $appPath!,
@@ -405,11 +407,13 @@
})
// `version` is what is deployed now, which is the deploy guard's fallback head and
// must stay set; `claimed` is the version this deploy can prove it wrote.
const claimed = versionThisDeployWrote(appHistory, $userStore?.username, headBefore?.version)
const claimed = versionThisDeployWrote(appHistory, $userStore?.username, anchor)
version = appHistory[0]?.version
// Without a claim the head is someone else's as far as this editor knows, so it is
// no longer a base it may compare against: `compareVersions` confirms instead.
baseUnknown = claimed === undefined
// A deploy landed beside this one, so the head is someone else's as far as this
// editor knows and is no longer a base it may compare against: `compareVersions`
// confirms instead. A failed anchor read claims nothing either, but it is no
// evidence of that, so it does not arm this.
baseUnknown = claimed === undefined && anchor != null
// Re-pin the fork base to the version just written: the editor stays open, so a
// follow-up deploy (or a new edit) would otherwise compare against the now-
// superseded base and falsely warn. parent_version is in
@@ -7,6 +7,9 @@ export function computeSecretUrl(secretUrl: string) {
}
/**
* Shared by both app editors: classic and raw apps deploy into the same `app_version`
* table and read the same history, so the rule for claiming a version lives here once.
*
* The version a just-finished deploy wrote: this caller's newest entry, and only while it
* sits directly on `headBefore`, the head read just before the write. That is what the
* write appended, so anything else in between belongs to a deploy this cannot tell from
@@ -98,10 +98,10 @@
// staleness outright.
const useVersion = $derived(draftBaseVersion != null && deployedHeadVersion != null)
const isStale = $derived(
// Both paths need a draft to be out of date: the editors clear `draftSavedAt`
// when a deploy consumes theirs, and the version pair stays armed for the draft
// the next edit starts, which is what the prompt would otherwise offer to
// discard seconds after a successful deploy.
// Both paths need a draft to be out of date: the app editors stay open across a
// deploy and clear `draftSavedAt` when it consumes theirs, while the version pair
// stays armed for the draft the next edit starts, which is what the prompt would
// otherwise offer to discard seconds after a successful deploy.
!!onLoadLatestDeploy &&
!!draftSavedAt &&
(useVersion
@@ -521,10 +521,12 @@
}
// Read the head this deploy is about to append to, so the entry it writes can be
// told from one landing beside it (see versionThisDeployWrote).
const headBefore = await AppService.getAppLatestVersion({
workspace: opWorkspace!,
path: appPath!
}).catch(() => undefined)
const anchor = (
await AppService.getAppLatestVersion({
workspace: opWorkspace!,
path: appPath!
}).catch(() => undefined)
)?.version
await AppService.updateAppRaw({
workspace: opWorkspace!,
path: appPath!,
@@ -563,11 +565,13 @@
// draft's base; the head is what is deployed now. The route owns this `version`
// prop and re-pushes `parentVersion ?? head` as soon as `onDeploy` returns, so the
// guard compares the claimed base when there is one and the head otherwise.
const claimed = versionThisDeployWrote(appHistory, $userStore?.username, headBefore?.version)
const claimed = versionThisDeployWrote(appHistory, $userStore?.username, anchor)
version = appHistory[0]?.version
// Without a claim the head is someone else's as far as this editor knows, so it is
// no longer a base it may compare against: `compareVersions` confirms instead.
baseUnknown = claimed === undefined
// A deploy landed beside this one, so the head is someone else's as far as this
// editor knows and is no longer a base it may compare against: `compareVersions`
// confirms instead. A failed anchor read claims nothing either, but it is no
// evidence of that, so it does not arm this.
baseUnknown = claimed === undefined && anchor != null
closeSaveDrawer()
sendUserToast('App deployed successfully')