fix(drafts): settle what a schedule toggle queues, and void a removal marker any write outlives

Toggling enable writes the form before awaiting the deploy, so the autosave
queues a draft against the pre-toggle baseline. Advancing the baseline on
success then made that draft equal to it — invisible to the banner, still a row
on the server and a `*` on the list. It is settled now, keeping an unrelated
edit made during the request the way a save does.

A draft-only removal marker was only ever voided by a chat write, so one left
unread — discarded outside a session, where nothing consumes it — could be spent
by an ordinary discard on a path since recreated, sending live editors away from
an item that exists. Every write that gives the item a draft again voids it,
which is also what `persistGlobalDraft` was doing by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
Diego Imbert
2026-09-06 10:08:35 +02:00
co-authored by Claude Opus 5
parent 069d66627d
commit bfc9db5dfd
3 changed files with 25 additions and 14 deletions
@@ -421,9 +421,6 @@ export async function persistGlobalDraft(
const itemKind = itemKindFor(type, opts.triggerKind)
if (!itemKind) throw new Error(`Unsupported draft type "${type}".`)
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
// Writing the item back means it exists again, so any removal marker left from
// an earlier draft-only discard is void.
UserDraft.clearDraftOnlyDiscard(itemKind, storagePath, { workspace })
UserDraft.seed(itemKind, storagePath, value, { workspace })
await UserDraftDbSyncer.save({
workspace,
@@ -626,10 +626,10 @@
const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!)
if (isSaved) {
// A create deploys the schedule enabled whatever the form said (see
// saveScheduleFromCfg), so what counts as written records that — otherwise
// the baseline below claims a state the server does not have. The form
// follows only while it still holds what was sent: changed during the
// request, it is a newer edit, and `keptEdit` below is what protects it.
// saveScheduleFromCfg), so what counts as written records that — the
// baseline would otherwise claim a state the server does not have. The form
// follows only while it still holds what was sent; changed since, it is an
// edit of its own.
if (wasCreate) {
const sentEnabled = scheduleCfg.enabled
scheduleCfg.enabled = true
@@ -776,7 +776,20 @@
if (initialConfig) initialConfig.enabled = nEnabled
// This request carried the enabled flag alone: anything else the form has
// diverged into is an edit of the user's, which a remount would drop.
onUpdate?.(path, path, ws, !draftValuesEqual(getScheduleCfg(), initialConfig))
const keptEdit = !draftValuesEqual(getScheduleCfg(), initialConfig)
// Setting `enabled` above queued a draft against the pre-toggle baseline.
// It matches the new one, so nothing would ever show it again — but it is
// still a row on the server and a `*` on the list until it is settled away.
if (initialConfig)
settleDraftAfterWrite(
'trigger_schedule',
$state.snapshot(initialConfig),
getScheduleCfg(),
path,
path,
{ workspace: ws ?? undefined }
)
onUpdate?.(path, path, ws, keptEdit)
}
}
+7 -6
View File
@@ -320,6 +320,10 @@ export const UserDraft = {
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
// The item has a draft again, so it exists again: a marker still standing
// (nothing consumed it, because nothing was listening) describes an item this
// write replaced, and would send the next editor to touch it away.
draftOnlyDiscards.delete(mk)
const entry = entries.get(mk)
if (entry) {
// The reactive effect in `acquireEntry` observes this write
@@ -463,6 +467,7 @@ export const UserDraft = {
seed<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
draftOnlyDiscards.delete(mk)
const entry = entries.get(mk)
if (!entry) {
noteMarker(seedMisses, mk)
@@ -483,12 +488,6 @@ export const UserDraft = {
noteMarker(draftOnlyDiscards, mapKey(resolveWorkspace(opts), itemKind, path))
},
/** Drop any removal marker for this cell — the item exists again, so a marker
* still standing (nothing consumed it, because nothing was listening) would be
* read by whatever touches it next and send its editor away. */
clearDraftOnlyDiscard(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
draftOnlyDiscards.delete(mapKey(resolveWorkspace(opts), itemKind, path))
},
/** Whether the last discard for this cell removed the item outright (see
* {@link recordDraftOnlyDiscard}), clearing the record. */
@@ -953,6 +952,8 @@ function acquireEntry(
// copy. `untrack` so reactive reads in the predicate (the editor's
// post-deploy baseline) don't re-fire the mirror.
const atBaseline = untrack(() => val !== undefined && (discardIf?.(val) ?? false))
// Same as `save`: a value persisted here means the item exists again.
if (val !== undefined && !atBaseline) draftOnlyDiscards.delete(mk)
void UserDraftDbSyncer.save({
workspace,
itemKind,