+ It was saved from another tab or session since this one read it, so your changes here are no
+ longer being saved.
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte
index d33b31b332..9847454aca 100644
--- a/frontend/src/lib/components/ResourceEditor.svelte
+++ b/frontend/src/lib/components/ResourceEditor.svelte
@@ -19,6 +19,8 @@
import { useActingUser } from '$lib/actingUser.svelte'
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
+ import { useDraftConflictSession } from '$lib/draftConflictSession.svelte'
+ import DraftConflictAlert from './DraftConflictAlert.svelte'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
import { onUserInput } from '$lib/userDraftEditGate'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -48,6 +50,9 @@
* so it can hide the banner's Discard button in read-only mode (matches
* the trigger editors' `disabled={!can_write}` wiring). */
onCanWriteChange?: (canWrite: boolean) => void
+ /** The drawer renders the conflict alert: inside this scrollable form it can land above the
+ * viewport on a long resource, leaving the fixed banner claiming the edits are saved. */
+ onDraftConflictChange?: (state: { conflicted: boolean; busy: boolean }) => void
}
let {
@@ -61,7 +66,8 @@
selected: selectedProp = $bindable(),
viewJsonSchema = $bindable(),
onDraftStateChange,
- onCanWriteChange
+ onCanWriteChange,
+ onDraftConflictChange
}: Props = $props()
type ResourceState = {
@@ -263,6 +269,132 @@
)
const anyDirty = $derived(dirtyWorkspaces.length > 0)
+ /** Scoped by `selected` so switching workspace-specific versions is a new session. Ending is
+ * exported on top of that because the drawer, not this component, knows when its own session
+ * is over: this component outlives it. */
+ const conflictSession = useDraftConflictSession(() => selected)
+ export function endEditingSession(): void {
+ conflictSession.end()
+ }
+ onDestroy(endEditingSession)
+ const resolvingConflict = $derived(conflictSession.busy)
+ /** The server refused this tab's autosave because the row moved under it: another tab, or the
+ * AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
+ * picks a version, and the unsaved-changes banner says the opposite — that the edits are held
+ * as a draft — so without this they are told their work is safe while it is being dropped. */
+ const draftConflict = $derived(
+ selected && initialPath
+ ? UserDraftDbSyncer.getConflict({
+ workspace: selected,
+ itemKind: 'resource',
+ path: initialPath
+ }).conflict
+ : undefined
+ )
+
+ async function resolveDraftConflict(keepMine: boolean): Promise {
+ const ws = selected
+ const p = initialPath
+ if (!ws || !p || resolvingConflict) return
+ const query = { workspace: ws, itemKind: 'resource' as const, path: p }
+ const token = conflictSession.start()
+ const stillOurs = () => conflictSession.holds(token) && selected === ws
+ try {
+ if (keepMine) {
+ // Settle the key first: an ordinary autosave still queued would displace the forced
+ // write below, and being conditional it would be refused — so "Keep mine" would
+ // finish without keeping anything and leave the alert standing.
+ await UserDraftDbSyncer.quiesce(query)
+ // A resolution belongs to the session that started it. One that outlives its editor
+ // stops here rather than writing on: whatever replaced it — another session on the
+ // same draft, or its own resolution — owns the key now, and the edit this one was
+ // keeping is still parked for a later flush either way.
+ if (!stillOurs()) return
+ // A parked `null` is this tab's "no draft any more" — a discard, or an edit that
+ // landed back on the deployed value. Keeping that means removing the row, not
+ // writing the baseline back as a draft with no dirty banner to discard it through.
+ const parked = UserDraftDbSyncer.peekPending(query)
+ const mine = parked?.value === null ? null : $state.snapshot(states[ws]?.draft)
+ // Forced, so it goes over the row that refused us, and its response reseeds
+ // `last_sync` so the next ordinary save is conditional again.
+ if (mine !== undefined) await UserDraftDbSyncer.overwrite({ ...query, value: mine })
+ // Say so rather than leave the alert up with no explanation: a write displaced by
+ // something typed meanwhile can still lose the race.
+ if (UserDraftDbSyncer.getConflict(query).conflict) {
+ sendUserToast('Could not keep your version — try again', true)
+ }
+ return
+ }
+ // Settle the key BEFORE reading, so what comes back is the version the server is left
+ // holding: a write this tab started can still be in flight — a forced one it walked
+ // away from included — and a response fetched past it describes a version about to be
+ // replaced, which would then be seeded along with its already-stale `last_sync`.
+ // Nothing is given up by waiting; `quiesce` only stops the pipeline.
+ await UserDraftDbSyncer.quiesce(query)
+ if (!stillOurs()) return
+ // Read BEFORE giving anything up: until the server has answered, the refused payload is
+ // still the only copy of this tab's edit, and the conflict is still true.
+ const r = await ResourceService.getResource({ workspace: ws, path: p, getDraft: true })
+ const deployedState: ResourceState = {
+ path: r.path,
+ args: (r.value ?? {}) as Record,
+ description: r.description ?? '',
+ labels: r.labels ?? undefined,
+ wsSpecific: r.ws_specific ?? false
+ }
+ // Everything below writes shared editor state, so first make sure it is still this
+ // resource's: the drawer stays closable while the read is out, and another resource
+ // opened meanwhile would otherwise get this one's baseline — and with it this one's
+ // path as its save target.
+ if (!stillOurs()) return
+ // Again, because the form stayed editable while the read was out: `quiesce` settles what
+ // is running when it is called, not the key for the rest of the resolution, so a
+ // keystroke since can have started a save of its own. Left running, its rejection lands
+ // after the baseline below and raises the conflict this just resolved.
+ await UserDraftDbSyncer.quiesce(query)
+ if (!stillOurs()) return
+ // Now, and not in `quiesce`: the refused payload belongs to the version being replaced,
+ // but until this point it was still the only copy of the edit, and a resolution that
+ // gave up before here has to leave it behind.
+ UserDraftDbSyncer.dropPending(query)
+ UserDraftDbSyncer.clearConflict(query)
+ initialStates[ws] = structuredClone(deployedState)
+ // Everything else the load path takes from this same response. The item can have been
+ // deleted, recreated under another type, or had its permissions changed while the
+ // conflict stood, and the fields below decide create-vs-update, the schema and write
+ // access — so refreshing only what is displayed would leave those deciding on the
+ // version the user just replaced.
+ fetchedResources[ws] = r
+ existedInitially[ws] = !(r as any).no_deployed
+ if (ws === effectiveWorkspace) resource_type = r.resource_type
+ UserDraftDbSyncer.recordRemoteSync(query, (r as any).draft_saved_at)
+ const loadedDraft = (r as any).draft as ResourceState | undefined
+ // Loading a draft makes this workspace one that opened with a draft, whatever it opened
+ // with before — a refused deletion opens gated, and gated the settling absorber would
+ // fold the version just loaded into the deployed baseline, leaving it silently clean
+ // with Save disabled. Ungate here rather than leaving it to the effect, so no write
+ // between the two is absorbed.
+ openedOnDraft[ws] = !!loadedDraft
+ if (loadedDraft) {
+ setGated(ws, false)
+ } else {
+ // Accepting "there is no draft" has to shut the gate, the way discarding one does.
+ // The edit that raised the conflict set `userEdited`, and left open, the form's
+ // settling writes — schema defaults materializing over the loaded value — read as
+ // the user's and recreate the draft just accepted away.
+ userEdited[ws] = false
+ setGated(ws, true)
+ }
+ UserDraft.seed('resource', p, loadedDraft ?? deployedState, { workspace: ws })
+ } catch (e) {
+ // Nothing was given up above, so the conflict stands and the edit is still here to
+ // resolve again — which is the whole point of reading first.
+ sendUserToast(`Could not load the other version: ${e}`, true)
+ } finally {
+ conflictSession.finish(token)
+ }
+ }
+
// The syncer owns the list-page `*` hint; the editor only CLEARS it when a
// workspace is at the deployed baseline (so a draft discarded elsewhere
// vanishes on reopen). Never SET here. See VariableEditor for the full note.
@@ -338,15 +470,33 @@
labels: r.labels ?? undefined,
wsSpecific: r.ws_specific ?? false
}
- // Open with the saved draft if present, else the deployed.
- const s: ResourceState = savedDraftState ?? deployedState
- openedOnDraft[ws] = !!savedDraftState
+ // A refused save leaves this tab's own version parked. `.draft` is the
+ // version that refused it, so opening on that would quietly drop the edit
+ // the alert is about and leave "Keep mine" offering to keep the other one.
+ const conflictQuery = {
+ workspace: ws,
+ itemKind: 'resource' as const,
+ path: initialPath
+ }
+ const refused = UserDraftDbSyncer.getConflict(conflictQuery).conflict
+ ? UserDraftDbSyncer.peekPending(conflictQuery)
+ : undefined
+ // A parked `null` is this tab's "no draft any more", which on screen is the
+ // deployed value — so only a payload with content counts as a local draft.
+ const refusedDraft = (refused?.value ?? undefined) as ResourceState | undefined
+ const hasLocalDraft = refused ? refused.value !== null : !!savedDraftState
+ // Open with this tab's refused version if there is one, else the saved
+ // draft, else the deployed.
+ const s: ResourceState = refused
+ ? (refusedDraft ?? deployedState)
+ : (savedDraftState ?? deployedState)
+ openedOnDraft[ws] = hasLocalDraft
// Gate BEFORE the handle is acquired: `stopSync` queues on a
// not-yet-live entry, and the form can settle before the effect
// above gets a chance to run. Only worth doing when no draft exists
// yet — where one does, there is no phantom to prevent and
// suspending could only drop a write.
- if (!savedDraftState) setGated(ws, true)
+ if (!hasLocalDraft) setGated(ws, true)
ensureHandle(ws, s)
initialStates[ws] = structuredClone(deployedState)
// Draft-only paths (`no_deployed`) have no row — saving must
@@ -426,6 +576,13 @@
$effect(() => {
onCanWriteChange?.(can_write === true)
})
+ $effect(() => {
+ onDraftConflictChange?.({ conflicted: !!draftConflict, busy: resolvingConflict })
+ })
+
+ export function resolveDraftConflictFromBanner(keepMine: boolean): void {
+ void resolveDraftConflict(keepMine)
+ }
export function localDraftDeployed(): ResourceState | undefined {
return selected ? initialStates[selected] : undefined
@@ -539,6 +696,18 @@
+
+ {#if draftConflict && !onDraftConflictChange}
+ void resolveDraftConflict(false)}
+ onOverwrite={() => void resolveDraftConflict(true)}
+ />
+ {/if}
+
{#if otherDirty.length > 0}
You are going to edit the value in: {otherDirty.join(', ')}
diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte
index 7259fff633..ce4d1eea5a 100644
--- a/frontend/src/lib/components/ResourceEditorDrawer.svelte
+++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte
@@ -8,6 +8,7 @@
import { isOwner } from '$lib/utils'
import { useActingUser } from '$lib/actingUser.svelte'
import LocalDraftBanner from './LocalDraftBanner.svelte'
+ import DraftConflictAlert from './DraftConflictAlert.svelte'
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import {
clearPageDrawerAnchor,
@@ -62,9 +63,12 @@
localDraftDeployed: () => unknown
localDraftCurrent: () => unknown
discardLocalDraft: () => void
+ endEditingSession: () => void
+ resolveDraftConflictFromBanner: (keepMine: boolean) => void
}
| undefined = $state(undefined)
let hasLocalDraft = $state(false)
+ let draftConflict = $state({ conflicted: false, busy: false })
let canWriteSelected = $state(true)
let path: string | undefined = $state(undefined)
@@ -151,6 +155,9 @@
size="50rem"
{disableChatOffset}
on:close={() => {
+ // The editor outlives this drawer, so tell it the session is over: a conflict resolution
+ // still in flight must not land on whatever the next opening shows.
+ resourceEditor?.endEditingSession?.()
if (keepAnchorOnClose) {
keepAnchorOnClose = false
return
@@ -168,7 +175,15 @@
bannerReserved={mode == 'edit'}
hideClose={inline && !onClose}
fullScreen={!inline}
- on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
+ on:close={() => {
+ // Inline has no drawer to emit a close, so the session ends here instead.
+ if (inline) {
+ resourceEditor?.endEditingSession?.()
+ onClose?.()
+ } else {
+ drawer?.closeDrawer()
+ }
+ }}
>
{#snippet titleExtra()}
{#if mode == 'new' && resource_type}
@@ -189,10 +204,18 @@
bind:selected
bind:viewJsonSchema
onDraftStateChange={(v) => (hasLocalDraft = v)}
+ onDraftConflictChange={(v) => (draftConflict = v)}
onCanWriteChange={(v) => (canWriteSelected = v)}
/>
{/await}
{#snippet banner()}
+ {#if draftConflict.conflicted}
+ resourceEditor?.resolveDraftConflictFromBanner?.(false)}
+ onOverwrite={() => resourceEditor?.resolveDraftConflictFromBanner?.(true)}
+ />
+ {/if}
import { VariableService, WorkspaceService } from '$lib/gen'
- import { createEventDispatcher, untrack } from 'svelte'
+ import { createEventDispatcher, onDestroy, untrack } from 'svelte'
import { Button } from './common'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
@@ -23,6 +23,9 @@
import { useActingUser } from '$lib/actingUser.svelte'
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
import LocalDraftBanner from './LocalDraftBanner.svelte'
+ import DraftConflictAlert from './DraftConflictAlert.svelte'
+ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
+ import { useDraftConflictSession } from '$lib/draftConflictSession.svelte'
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -141,6 +144,126 @@
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
)
+ /** Scoped by `selected` so switching workspace-specific versions is a new session. Ended on top
+ * of that by every entry point and by the teardown, since reopening the same variable reuses
+ * this component. */
+ const conflictSession = useDraftConflictSession(() => selected)
+ function endEditingSession(): void {
+ conflictSession.end()
+ }
+ onDestroy(endEditingSession)
+ const resolvingConflict = $derived(conflictSession.busy)
+ /** The server refused this tab's autosave because the row moved under it: another tab, or the
+ * AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
+ * picks a version, and the unsaved-changes banner says the opposite — that the edits are held
+ * as a draft — so without this they are told their work is safe while it is being dropped. */
+ const draftConflict = $derived(
+ edit && selected && editPath
+ ? UserDraftDbSyncer.getConflict({
+ workspace: selected,
+ itemKind: 'variable',
+ path: editPath
+ }).conflict
+ : undefined
+ )
+
+ async function resolveDraftConflict(keepMine: boolean): Promise {
+ const ws = selected
+ const p = editPath
+ if (!ws || !p || resolvingConflict) return
+ const query = { workspace: ws, itemKind: 'variable' as const, path: p }
+ const token = conflictSession.start()
+ const stillOurs = () => conflictSession.holds(token) && selected === ws
+ try {
+ if (keepMine) {
+ // Settle the key first: an ordinary autosave still queued would displace the forced
+ // write below, and being conditional it would be refused — so "Keep mine" would
+ // finish without keeping anything and leave the alert standing.
+ await UserDraftDbSyncer.quiesce(query)
+ // A resolution belongs to the session that started it. One that outlives its editor
+ // stops here rather than writing on: whatever replaced it — another session on the
+ // same draft, or its own resolution — owns the key now, and the edit this one was
+ // keeping is still parked for a later flush either way.
+ if (!stillOurs()) return
+ // A parked `null` is this tab's "no draft any more" — a discard, or an edit that
+ // landed back on the deployed value. Keeping that means removing the row, not
+ // writing the baseline back as a draft with no dirty banner to discard it through.
+ const parked = UserDraftDbSyncer.peekPending(query)
+ const mine = parked?.value === null ? null : $state.snapshot(states[ws]?.draft)
+ // Forced, so it goes over the row that refused us, and its response reseeds
+ // `last_sync` so the next ordinary save is conditional again.
+ if (mine !== undefined) await UserDraftDbSyncer.overwrite({ ...query, value: mine })
+ // Say so rather than leave the alert up with no explanation: a write displaced by
+ // something typed meanwhile can still lose the race.
+ if (UserDraftDbSyncer.getConflict(query).conflict) {
+ sendUserToast('Could not keep your version — try again', true)
+ }
+ return
+ }
+ // Settle the key BEFORE reading, so what comes back is the version the server is left
+ // holding: a write this tab started can still be in flight — a forced one it walked
+ // away from included — and a response fetched past it describes a version about to be
+ // replaced, which would then be seeded along with its already-stale `last_sync`.
+ // Nothing is given up by waiting; `quiesce` only stops the pipeline.
+ await UserDraftDbSyncer.quiesce(query)
+ if (!stillOurs()) return
+ // Read BEFORE giving anything up: until the server has answered, the refused payload is
+ // still the only copy of this tab's edit, and the conflict is still true.
+ const v = await VariableService.getVariable({
+ workspace: ws,
+ path: p,
+ decryptSecret: false,
+ getDraft: true
+ })
+ const deployedState: VariableState = {
+ path: v.path,
+ variable: {
+ value: v.value ?? '',
+ is_secret: v.is_secret,
+ description: v.description ?? ''
+ },
+ labels: v.labels ?? undefined,
+ wsSpecific: v.ws_specific ?? false
+ }
+ // Everything below writes shared editor state, so first make sure it is still this
+ // variable's: the drawer stays closable while the read is out, and another variable
+ // opened meanwhile would otherwise get this one's baseline — and with it this one's
+ // path as its save target.
+ if (!stillOurs()) return
+ // Again, because the form stayed editable while the read was out: `quiesce` settles what
+ // is running when it is called, not the key for the rest of the resolution, so a
+ // keystroke since can have started a save of its own. Left running, its rejection lands
+ // after the baseline below and raises the conflict this just resolved.
+ await UserDraftDbSyncer.quiesce(query)
+ if (!stillOurs()) return
+ // Now, and not in `quiesce`: the refused payload belongs to the version being replaced,
+ // but until this point it was still the only copy of the edit, and a resolution that
+ // gave up before here has to leave it behind.
+ UserDraftDbSyncer.dropPending(query)
+ UserDraftDbSyncer.clearConflict(query)
+ initialStates[ws] = structuredClone(deployedState)
+ // Everything else the load path takes from this same response. The variable can have
+ // been deleted, recreated, or had its permissions changed while the conflict stood,
+ // and these decide create-vs-update and write access — so refreshing only what is
+ // displayed would leave those deciding on the version the user just replaced.
+ existedInitially[ws] = !(v as any).no_deployed
+ extraPerms[ws] = v.extra_perms ?? {}
+ UserDraftDbSyncer.recordRemoteSync(query, (v as any).draft_saved_at)
+ UserDraft.seed(
+ 'variable',
+ p,
+ ((v as any).draft as VariableState | undefined) ?? deployedState,
+ { workspace: ws }
+ )
+ } catch (e) {
+ // Nothing was given up above, so the conflict stands and the edit is still here to
+ // resolve again — which is the whole point of reading first.
+ sendUserToast(`Could not load the other version: ${e}`, true)
+ } finally {
+ conflictSession.finish(token)
+ }
+ }
+
// The list-page `*` hint is owned by UserDraftDbSyncer (set on save, cleared
// on delete). The editor only CLEARS it — a workspace at the deployed
// baseline has no draft, so drop any stale hint (this is how a draft
@@ -210,8 +333,21 @@
labels: v.labels ?? undefined,
wsSpecific: v.ws_specific ?? false
}
- // Open with the saved draft if present, else the deployed.
- const s: VariableState = savedDraftState ?? deployedState
+ // A refused save leaves this tab's own version parked. `.draft` is the version
+ // that refused it, so opening on that would quietly drop the edit the alert is
+ // about and leave "Keep mine" offering to keep the other one.
+ const conflictQuery = { workspace: ws, itemKind: 'variable' as const, path: p }
+ const refused = UserDraftDbSyncer.getConflict(conflictQuery).conflict
+ ? UserDraftDbSyncer.peekPending(conflictQuery)
+ : undefined
+ // A parked `null` is this tab's "no draft any more", which on screen is the
+ // deployed value.
+ const refusedDraft = (refused?.value ?? undefined) as VariableState | undefined
+ // Open with this tab's refused version if there is one, else the saved draft,
+ // else the deployed.
+ const s: VariableState = refused
+ ? (refusedDraft ?? deployedState)
+ : (savedDraftState ?? deployedState)
ensureHandle(ws, s)
initialStates[ws] = structuredClone(deployedState)
// Draft-only paths (`no_deployed`) have no row — saving must
@@ -223,6 +359,8 @@
})
function reset() {
+ // A new session starts here, so anything still running for the last one is spent.
+ endEditingSession()
// Clearing workspaceSpecs triggers useMany's reconcile to release
// every acquired entry. The $derived `states` then collapses to {}.
workspaceSpecs = []
@@ -334,7 +472,14 @@
{#if inline}
{@render content()}
{:else}
- clearPageDrawerAnchor(VARIABLES_PATH)}>
+ {
+ endEditingSession()
+ clearPageDrawerAnchor(VARIABLES_PATH)
+ }}
+ >
{@render content()}
{/if}
@@ -345,9 +490,24 @@
bannerReserved={edit}
hideClose={inline && !onClose}
fullScreen={!inline}
- on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
+ on:close={() => {
+ // Inline has no drawer to emit a close, so the session ends here instead.
+ if (inline) {
+ endEditingSession()
+ onClose?.()
+ } else {
+ drawer?.closeDrawer()
+ }
+ }}
>
{#snippet banner()}
+ {#if draftConflict}
+ void resolveDraftConflict(false)}
+ onOverwrite={() => void resolveDraftConflict(true)}
+ />
+ {/if}
unknown) {
+ const session = createDraftConflictSession()
+
+ let lastScope = untrack(scopeOf)
+ $effect(() => {
+ const next = scopeOf()
+ untrack(() => {
+ if (next === lastScope) return
+ lastScope = next
+ session.end()
+ })
+ })
+
+ return session
+}
diff --git a/frontend/src/lib/draftConflictSession.test.ts b/frontend/src/lib/draftConflictSession.test.ts
new file mode 100644
index 0000000000..88e62f44de
--- /dev/null
+++ b/frontend/src/lib/draftConflictSession.test.ts
@@ -0,0 +1,31 @@
+import { describe, it, expect } from 'vitest'
+
+import { createDraftConflictSession } from './draftConflictSession.svelte'
+
+/**
+ * A resolution the user has walked away from must neither speak for what replaced it nor hold its
+ * buttons disabled until it settles.
+ */
+describe('createDraftConflictSession', () => {
+ it('frees the next session from a resolution the last one left in flight', () => {
+ const session = createDraftConflictSession()
+
+ const stale = session.start()
+ expect(session.busy).toBe(true)
+
+ session.end()
+ expect(session.busy).toBe(false)
+ expect(session.holds(stale)).toBe(false)
+
+ const fresh = session.start()
+ expect(session.busy).toBe(true)
+ expect(session.holds(fresh)).toBe(true)
+
+ // The stale request settles last: it must not release the live session's claim.
+ session.finish(stale)
+ expect(session.busy).toBe(true)
+
+ session.finish(fresh)
+ expect(session.busy).toBe(false)
+ })
+})
diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts
index 25125a74e4..e383213cfe 100644
--- a/frontend/src/lib/userDraftDbSyncer.svelte.ts
+++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts
@@ -723,5 +723,35 @@ export const UserDraftDbSyncer = {
const key = draftKey(query.workspace, query.itemKind, query.path)
pendingSaveOpts.delete(key)
debouncer.cancel(key)
+ },
+
+ /**
+ * The value parked for this key, wrapped so a parked delete (`null`) stays distinguishable
+ * from nothing parked at all. While a conflict stands this is the version the server refused,
+ * which is to say this tab's own: an editor opening on the key takes it over the server's
+ * draft, which is the version that did the refusing.
+ */
+ peekPending(query: UserDraftLastSyncQuery): { value: unknown } | undefined {
+ const parked = pendingSaveOpts.get(draftKey(query.workspace, query.itemKind, query.path))
+ return parked ? { value: parked.value } : undefined
+ },
+
+ /**
+ * Stop scheduling saves for this key and wait until nothing for it is still in flight.
+ * Cancelling alone cannot stop a POST the runner already started, and such a POST settles
+ * *after* the caller has moved on — a rejected one re-raising the conflict it was told to
+ * resolve. Await this before installing a baseline that would make a stale payload acceptable.
+ *
+ * What is parked is deliberately left alone: a refused save keeps its payload here, and while
+ * the conflict stands that is the only copy of the edit outside the editor's own memory. A
+ * caller that gives up half way must leave it recoverable, so dropping it is the committing
+ * caller's job, via `dropPending`, once it has something to replace it with.
+ */
+ async quiesce(query: UserDraftLastSyncQuery): Promise {
+ const key = draftKey(query.workspace, query.itemKind, query.path)
+ debouncer.cancel(key)
+ await runner.settled(key)
+ // A save that landed while we waited can have scheduled the next one.
+ debouncer.cancel(key)
}
}
diff --git a/frontend/src/lib/userDraftQuiesce.test.ts b/frontend/src/lib/userDraftQuiesce.test.ts
new file mode 100644
index 0000000000..b6c38740da
--- /dev/null
+++ b/frontend/src/lib/userDraftQuiesce.test.ts
@@ -0,0 +1,126 @@
+import { describe, it, expect, afterEach, vi } from 'vitest'
+
+// Mocked so a test can hold a POST in flight: that window is the whole point of `quiesce`,
+// which exists to wait one out rather than return while it is still going.
+const updateDraft = vi.fn(async (..._args: any[]) => ({
+ status: 'saved' as const,
+ current_timestamp: '2020-01-01T00:00:00Z'
+}))
+
+vi.mock('./gen', () => ({
+ DraftService: { updateDraft: (...a: unknown[]) => updateDraft(...(a as [])) }
+}))
+vi.mock('./gen/core/OpenAPI', () => ({ OpenAPI: { BASE: '' } }))
+vi.mock('./localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() }))
+
+import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte'
+
+function deferred() {
+ let resolve!: (v: T) => void
+ const promise = new Promise((res) => (resolve = res))
+ return { promise, resolve }
+}
+
+afterEach(() => {
+ vi.clearAllMocks()
+ updateDraft.mockResolvedValue({ status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' })
+})
+
+/**
+ * Resolving a draft conflict installs a fresh baseline. Anything of the old version still in the
+ * air when that lands would be made acceptable by it, and a rejected one re-raises the conflict
+ * just resolved — so the resolution waits for the key to go quiet first. What it must NOT do is
+ * discard the payload while waiting: a refused save keeps it here, and until the resolution
+ * commits it is the only copy of the edit outside the editor's own memory.
+ */
+describe('UserDraftDbSyncer.quiesce', () => {
+ it('waits for a save already in flight', async () => {
+ const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_a' }
+ const inFlight = deferred()
+ let settledBeforeQuiesceReturned = false
+ updateDraft.mockImplementationOnce(async () => {
+ await inFlight.promise
+ settledBeforeQuiesceReturned = true
+ return { status: 'saved', current_timestamp: '2020-01-01T00:00:00Z' }
+ })
+
+ // In flight, not awaited: the POST is held open by the mock above.
+ const saving = UserDraftDbSyncer.save({ ...q, value: { v: 'in flight' }, immediate: true })
+
+ let quiesced = false
+ const quiescing = UserDraftDbSyncer.quiesce(q).then(() => (quiesced = true))
+ // A full macrotask, not a microtask: anything that merely yields would have resolved by
+ // now, so this is what tells "waited for the POST" apart from "waited for nothing".
+ await new Promise((r) => setTimeout(r, 0))
+ expect(quiesced).toBe(false)
+
+ inFlight.resolve()
+ await quiescing
+ await saving
+ expect(settledBeforeQuiesceReturned).toBe(true)
+ })
+
+ it('keeps a refused payload recoverable, until the resolution that replaces it says otherwise', async () => {
+ const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_b' }
+ // Refused, so the payload stays parked — the state a conflict resolution starts from.
+ updateDraft.mockResolvedValueOnce({
+ status: 'conflict',
+ current_timestamp: '2020-01-02T00:00:00Z'
+ })
+ await UserDraftDbSyncer.save({ ...q, value: { v: 'refused' }, immediate: true })
+ expect(UserDraftDbSyncer.getConflict(q).conflict).toBeTruthy()
+
+ await UserDraftDbSyncer.quiesce(q)
+
+ // A resolution abandoned here (the editor closed while quiescing) must leave the edit
+ // somewhere it can still be sent. Flushing is how that payload is observed, since the
+ // parked opts themselves are private to the syncer.
+ updateDraft.mockClear()
+ await UserDraftDbSyncer.flush(q)
+ expect(updateDraft).toHaveBeenCalledTimes(1)
+ expect(updateDraft.mock.calls[0][0]).toMatchObject({ requestBody: { value: { v: 'refused' } } })
+
+ // Only the caller that has something to put in its place drops it.
+ UserDraftDbSyncer.dropPending(q)
+ updateDraft.mockClear()
+ await UserDraftDbSyncer.flush(q)
+ expect(updateDraft).not.toHaveBeenCalled()
+ })
+
+ it('keeps a parked delete distinguishable from nothing parked', async () => {
+ const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_delete' }
+
+ expect(UserDraftDbSyncer.peekPending(q)).toBeUndefined()
+
+ // Reverting to the deployed value, or discarding, parks a delete. Refused, it stays
+ // parked — and a resolution reading it has to keep meaning "remove the row", not fall
+ // back to whatever the server holds.
+ updateDraft.mockResolvedValueOnce({
+ status: 'conflict',
+ current_timestamp: '2020-01-02T00:00:00Z'
+ })
+ await UserDraftDbSyncer.save({ ...q, value: null, immediate: true })
+
+ expect(UserDraftDbSyncer.peekPending(q)).toEqual({ value: null })
+ })
+
+ it('cancels a debounced autosave so it cannot displace the write that follows', async () => {
+ const q = { workspace: 'w', itemKind: 'variable' as const, path: 'u/me/quiesce_c' }
+ vi.useFakeTimers()
+ try {
+ // Debounced rather than immediate: this is the autosave a resolution has to call off, or
+ // it fires mid-resolution and, being conditional, is refused.
+ void UserDraftDbSyncer.save({ ...q, value: { v: 'queued' } })
+
+ await UserDraftDbSyncer.quiesce(q)
+ updateDraft.mockClear()
+
+ // Past the debouncer's 10s ceiling, so a schedule that survived has certainly fired.
+ // Real time would not reach it: the test would pass with the cancelling removed.
+ await vi.advanceTimersByTimeAsync(11000)
+ expect(updateDraft).not.toHaveBeenCalled()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})