Files
windmill/frontend/src/lib/userDraftPrune.ts
T
c3f7f8a458 fix: stop an untouched item's form from saving a draft nobody wrote (#10964)
* feat: gate drafts on real user input so a moved-on schema is not a draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* Revert "feat: gate drafts on real user input so a moved-on schema is not a draft"

This reverts commit 6cd86cf727.

* fix: stop counting empty schema-added fields and server metadata as drafts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* feat: sweep away existing drafts that carry no changes, once per workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* Reapply "feat: gate drafts on real user input so a moved-on schema is not a draft"

This reverts commit b7b18e345e.

* Revert "fix: stop counting empty schema-added fields and server metadata as drafts"

This reverts commit 9787270ad8.

* docs: describe the sweep by the gate that now prevents new phantom drafts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: make the draft sweep a compare-and-delete so it cannot eat live edits

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: close the gate's load-time window and stop sealing a failed sweep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: open the gate on the edit itself, and stop the sweep at ownerless drafts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: count a click as an edit, and keep an unjudged row from sealing the sweep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: release the sweep's sync baseline when its delete is refused

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: drop the refused delete before re-baselining, and bound the sweep's retries

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* refactor: send the sweep's delete straight to the API, not through the syncer

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: never absorb a change the resource type's schema could not have made

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: push an edit the gate only notices after the write has landed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* fix: stop the gating effect re-suspending a resource opened on a draft

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f

* chore: update ee-repo-ref to d33ea730c550cdbc7d050aeb6d40dcef3d134e07

This commit updates the EE repository reference after PR #782 was merged in windmill-ee-private.

Previous ee-repo-ref: 313c572c9dcbcaafd8a1594df4054f9dd26f395c

New ee-repo-ref: d33ea730c550cdbc7d050aeb6d40dcef3d134e07

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-07 19:31:59 +02:00

231 lines
8.7 KiB
TypeScript

/**
* One-off sweep that drops drafts carrying no changes.
*
* `onUserInput` stops new ones from being written; the ones already stored need
* this pass to clear. Runs once per (workspace, user) per browser, after the
* localStorage→DB migration so anything it just uploaded is swept too.
*
* Scoped to the kinds whose editors that gate covers. A script, flow or app
* draft only ever came from an explicit edit, so there is no phantom to clear
* there — and `getDraftDiffValues` would fetch each one's full deployed payload
* at login to prove it.
*
* A draft is dropped only when the diff the user would be shown is empty: both
* sides come from `getDraftDiffValues`, the same canonicalization the diff
* drawer renders, compared with the same `draftValuesEqual` the editors use.
* Anything that can't be established is left alone — a `draft_only` item (no
* deployed counterpart, so discarding would destroy the item itself), a kind
* with no diff support, a failed fetch, and the legacy workspace-level rows,
* which belong to nobody and are admin-gated to migrate.
*
* Deleting is the dangerous half, and the equality behind it is always stale:
* it was read one round trip ago, and every candidate is read before any is
* deleted. So the delete is a compare-and-delete — `last_sync` is the
* timestamp the row was judged on, and the backend drops it only if nothing has
* written it since, whoever wrote it.
*
* It is sent straight to `DraftService`, NOT through `UserDraftDbSyncer`. That
* syncer exists to autosave an editor's own live value, and everything it does
* for that — parking the payload for the `pagehide` flush, debouncing, holding
* a per-tab `last_sync` baseline and conflict state — is a way for a one-shot
* delete to reach back into whatever the user is doing in the same tab. This
* sweep wants exactly one conditional request and no state afterwards.
*/
import { DraftService } from './gen'
import type { UserDraftItemKind } from './gen'
import { sendUserToast } from './toast'
import { setLocalDraftHint } from './localDraftHints.svelte'
import { UserDraft, draftValuesEqual } from './userDraft.svelte'
import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte'
import { canDiffDraftKind, getDraftDiffValues } from './utils_draft_deploy'
import { invalidateWorkspaceDrafts } from './workspaceDrafts.svelte'
const SENTINEL_PREFIX = 'userdraft/pruned/v1/'
/** A pass that leaves anything unresolved runs again next mount, which costs a
* listing plus an overlay GET per row. Bounded so no permanently-unresolvable
* row can make that repeat forever — whatever the reason it can't be judged. */
const MAX_PASSES = 3
/** The editors whose forms are built from a schema, and so the only kinds that
* could have banked a draft nobody wrote — minus the ones no diff can be
* computed for, which would throw and keep the pass unsealed forever. */
function isSweepableKind(kind: UserDraftItemKind): boolean {
return (kind === 'resource' || kind.startsWith('trigger_')) && canDiffDraftKind(kind)
}
/** Overlay GETs are one round trip each and a cluttered workspace has dozens;
* a small window keeps the sweep off the critical path of a fresh login. */
const CONCURRENCY = 4
/** Guards against the layout effect firing again before the sentinel lands. */
const inFlight = new Set<string>()
type Candidate = {
kind: UserDraftItemKind
path: string
/** The row's `created_at` as listed — the baseline the delete is conditioned on. */
createdAt: string
}
const attemptsKey = (sentinel: string) => `${sentinel}:attempts`
function readAttempts(sentinel: string): number {
try {
const n = Number(localStorage.getItem(attemptsKey(sentinel)))
return Number.isFinite(n) && n > 0 ? n : 0
} catch {
return 0
}
}
/** Is this tab holding or writing this draft right now? */
function busyLocally(workspace: string, kind: UserDraftItemKind, path: string): boolean {
if (UserDraft.has(kind, path, { workspace })) return true
return UserDraftDbSyncer.getState({ workspace, itemKind: kind, path }).state !== 'none'
}
/** A 4xx is the server's final answer for this row — the item is gone, or the
* kind's overlay endpoint isn't served by this build (a feature-gated trigger
* on CE). Retrying it on every page load would never succeed. Anything else
* (network, 5xx) is worth another pass. 429 asks for exactly that. */
function isPermanentlyUnjudgeable(e: unknown): boolean {
const status = (e as { status?: unknown })?.status
return typeof status === 'number' && status >= 400 && status < 500 && status !== 429
}
/** `undefined` when the diff could not be fetched and might be next time —
* distinct from `false`, so the caller can leave the pass open rather than
* strand a row it never judged. */
async function carriesNoChanges(
workspace: string,
{ kind, path }: Candidate
): Promise<boolean | undefined> {
try {
const { deployed, draft, hasDraft, noDeployed } = await getDraftDiffValues(
kind,
path,
workspace
)
// `hasDraft` false means the overlay had no draft row and the item's own
// value stood in for the draft side — there is nothing to discard, and the
// two sides would compare equal by construction.
if (!hasDraft || noDeployed) return false
return draftValuesEqual(draft, deployed)
} catch (e) {
return isPermanentlyUnjudgeable(e) ? false : undefined
}
}
async function mapWithLimit<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const out = new Array<R>(items.length)
let next = 0
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const i = next++
out[i] = await fn(items[i])
}
})
)
return out
}
export async function pruneMeaninglessDrafts(workspace: string, userKey: string): Promise<void> {
if (typeof localStorage === 'undefined') return
const sentinel = `${SENTINEL_PREFIX}${workspace}/${userKey}`
if (inFlight.has(sentinel)) return
try {
if (localStorage.getItem(sentinel)) return
} catch {
// Storage unavailable (private mode): the sweep can't record that it ran,
// and re-running it on every mount would cost an overlay GET per draft.
return
}
inFlight.add(sentinel)
try {
const rows = await DraftService.listDrafts({ workspace })
// Anything left unresolved keeps the pass open: a row skipped as busy was
// never judged, and one whose delete failed is still there. Sealing on
// either would strand it.
let unresolved = 0
const candidates: Candidate[] = rows
// `draft_only` rows ARE the item; `mine` / `can_write` are the same
// gate the discard endpoint enforces, so anything else would 403.
.filter((r) => !r.draft_only && r.mine && r.can_write)
.filter((r) => isSweepableKind(r.kind) && !r.legacy_draft)
.filter((r) => {
if (!busyLocally(workspace, r.kind, r.path)) return true
unresolved++
return false
})
.map((r) => ({
kind: r.kind,
path: r.path,
createdAt: r.created_at
}))
const empty: Candidate[] = []
await mapWithLimit(candidates, CONCURRENCY, async (c) => {
const verdict = await carriesNoChanges(workspace, c)
if (verdict === undefined) unresolved++
else if (verdict) empty.push(c)
})
let discarded = 0
for (const c of empty) {
// Re-check: the reads above took a while, and the user may have opened
// this item in the meantime.
if (busyLocally(workspace, c.kind, c.path)) {
unresolved++
continue
}
try {
const resp = await DraftService.updateDraft({
workspace,
kind: c.kind,
path: c.path,
requestBody: { value: null, last_sync: c.createdAt, force: false }
})
// `conflict` means the row moved past the timestamp we judged it on,
// so it is no longer the empty draft we decided to drop.
if (resp.status === 'saved') {
setLocalDraftHint(workspace, c.kind, c.path, false)
discarded++
}
} catch {
unresolved++
}
}
if (discarded > 0) {
invalidateWorkspaceDrafts(workspace)
sendUserToast(`Cleared ${discarded} draft${discarded > 1 ? 's' : ''} that carried no changes`)
}
// Seal once nothing is left hanging, or once we have tried enough times
// that whatever is hanging is not going to resolve.
const attempts = readAttempts(sentinel) + 1
if (unresolved === 0 || attempts >= MAX_PASSES) {
try {
localStorage.setItem(sentinel, new Date().toISOString())
localStorage.removeItem(attemptsKey(sentinel))
} catch {
// Nothing to do — the pass is idempotent, it just runs again.
}
} else {
try {
localStorage.setItem(attemptsKey(sentinel), String(attempts))
} catch {}
}
} catch {
// Fire-and-forget from the layout: a workspace whose draft list can't be
// read is left exactly as it was.
} finally {
inFlight.delete(sentinel)
}
}