Files
windmill/frontend/src/lib/userDraftDbSyncer.svelte.ts
T
Ruben FiszelandClaude Opus 5 d8ebbc06f5 fix: tell the user when a draft conflict has stopped their edits saving (#11241)
* fix: tell the user when a draft conflict has stopped their edits saving

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: give nothing up until the other version has actually loaded

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: ignore a reload the editor has moved on from, and settle saves before resolving

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: settle the key before a forced resolution, and cover quiesce with tests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: let a resolution tell it belongs to an editor that is gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: end a conflict resolution when the editing session does, not the component

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show the resource conflict in the fixed banner, and scope busy to its session

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the conflict alert in editors no host wraps

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: free a reopened editor from a resolution the closed one left in flight

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: make switching workspace-specific versions a new conflict session

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the local draft when a conflict resolution is abandoned

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: let a newer session outrank a resolution that outlived its editor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: end a conflict resolution with the session that started it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reopen a conflicted editor on the version the server refused

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep a refused draft deletion a deletion

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: ungate the editor when a resolution loads the other draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: settle the draft key before reading the version to load

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: settle the key again after reading, for edits made under the read

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 14:39:49 +00:00

758 lines
31 KiB
TypeScript

import { SvelteMap } from 'svelte/reactivity'
import { DraftService, type UserDraftItemKind } from './gen'
import { OpenAPI } from './gen/core/OpenAPI'
import { createCoalescingKeyedRunner, CoalescingDisplacedError } from './coalescingRunner.svelte'
import { createDebouncerByKey } from './debouncerByKey.svelte'
import { setLocalDraftHint } from './localDraftHints.svelte'
/**
* Per-draft baseline timestamp attached as `last_sync` on the next save so
* the backend can reject stale writes (`created_at <= last_sync`).
*
* MUST be tab-local, not localStorage: each tab tracks its own baseline.
* Sharing it would let tab-1's save advance tab-2's `last_sync` to a fresh
* timestamp, so tab-2's next save would pass the server's WHERE check and
* clobber tab-1 — the exact conflict this feature prevents. The cost: a
* fresh tab has no `last_sync` (first save lands unconditionally), but the
* editor's `recordRemoteSync` reseeds from the server timestamp on load
* before any user edit can save.
*/
type DraftLastSyncEntry = { lastSync: string }
const lastSyncMap = new Map<string, DraftLastSyncEntry>()
/** Must match `mapKey` in `userDraft.svelte.ts`. */
function draftKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
return `${workspace}/${itemKind}/${path}`
}
/**
* Top-level script fields stripped before a draft is persisted. `hash` is the
* deployed version's identity (server-managed, re-supplied from the deployed row
* on load) and `assets` is re-derived from the script content by the editor —
* neither is draft content, so saving them bloats the row and resurfaces as
* fork/workspace diff noise. The editing object carries them because
* `getScriptByPath` returns the full DB row (since #9351).
*
* `CLEANED_VALUE_KEYS` (frontend/src/lib/utils.ts) strips a SUPERSET of these
* from the diff view (it also drops `inherited_labels` and other bookkeeping
* keys). The two lists are deliberately NOT the same: the diff hides every
* non-content key, while this sanitizer only trims the two that meaningfully
* bloat the persisted draft. Just keep the hash/assets entries here in step
* with that set; the rest may diverge.
*/
const SCRIPT_DRAFT_OMITTED_FIELDS = ['hash', 'assets'] as const
/**
* Drop server-managed / re-derived fields a draft must not persist. Runs at the
* single persistence chokepoint (`save`) so every path — reactive autosave,
* Ctrl/Cmd+S flush, the `pagehide` keepalive — sends the same trimmed payload.
* Only scripts carry these fields; other kinds pass through untouched.
*/
export function sanitizeDraftValueForSave(
itemKind: UserDraftItemKind,
value: unknown | null
): unknown | null {
if (
itemKind !== 'script' ||
value === null ||
typeof value !== 'object' ||
Array.isArray(value)
) {
return value
}
const obj = value as Record<string, unknown>
if (!SCRIPT_DRAFT_OMITTED_FIELDS.some((f) => f in obj)) return value
const clone = { ...obj }
for (const f of SCRIPT_DRAFT_OMITTED_FIELDS) delete clone[f]
return clone
}
function getLastSyncEntry(key: string): DraftLastSyncEntry | undefined {
return lastSyncMap.get(key)
}
function setLastSync(
workspace: string,
itemKind: UserDraftItemKind,
path: string,
lastSync: string
): void {
lastSyncMap.set(draftKey(workspace, itemKind, path), { lastSync })
}
function clearLastSync(workspace: string, itemKind: UserDraftItemKind, path: string): void {
lastSyncMap.delete(draftKey(workspace, itemKind, path))
}
export type UserDraftDbSyncerSaveOpts = {
workspace: string
itemKind: UserDraftItemKind
path: string
/** `null` signals a delete — same conflict rules as an upsert. */
value: unknown | null
/** Bypass the debouncer: cancel any pending autosave for this key (it
* would otherwise overwrite what we send), route through the coalescing
* runner to preserve ordering against an in-flight POST, and resolve only
* once the key's save chain has drained. Use for
* `await save(...); read-the-server` flows where a fire-and-forget save
* would race the next read.
*
* Resolving means "the key is settled", NOT "your payload won": a newer
* save can displace this one (it then carries the later state), and — as
* with every other `save` — `postSave` routes a rejected or failed POST to
* `conflicts` / `failures` rather than throwing. Read those to know what
* actually landed. */
immediate?: boolean
/** Skip the optimistic-concurrency check and overwrite the server row.
* Used by the conflict-resolution UI ("Overwrite the remote"). Default
* `false`: autosaves attach `last_sync` and respect a reject. */
force?: boolean
/** Save came from the reactive autosave mirror, not an explicit user
* action (Ctrl/Cmd+S flush, discard, fork, overwrite). */
auto?: boolean
/** Source handle opts into the "Enable auto-save" toggle. Only the
* full-page editors (script / flow / app / raw app) set it; their
* `auto` saves are suppressed while the toggle is off (latest opts
* still park in `pendingSaveOpts` for an explicit flush). Drawer
* editors (variables / resources / triggers) leave it unset and always
* sync. Explicit saves always go through. */
canBeDisabled?: boolean
}
/**
* Snapshot of a rejected save. `localLastSync` is what we sent (or `null`
* if never synced); `serverTimestamp` is the row's current `created_at`,
* surfaced so the resolution UI can show how recent the conflict was.
*/
export type DraftConflictInfo = {
serverTimestamp: string
localLastSync: string | null
}
export type UserDraftLastSyncQuery = {
workspace: string
itemKind: UserDraftItemKind
path: string
}
/**
* Autosave lifecycle for a single draft:
* - `saving`: a POST is in flight (coalescing runner busy).
* - `pending`: a change is queued in the debouncer, not yet fired.
* - `failed`: the last POST threw (network / 5xx) and no later attempt
* succeeded. Conflicts go through the modal, not here.
* - `none`: in sync (or nothing happened).
* Render priority `saving` > `pending` > `failed` > `none`: an active
* retry outranks the prior failure so the indicator shows it in-flight.
*/
export type UserDraftSyncState = 'none' | 'pending' | 'saving' | 'failed'
export type UserDraftStateHandle = {
/** Reactive: re-runs as the draft moves through the pipeline. */
readonly state: UserDraftSyncState
/** When `state === 'failed'`, the message from the thrown error.
* `undefined` otherwise. */
readonly failureMessage: string | undefined
/** Reactive: bumped each time `flush()` completes, INCLUDING the no-op
* path. Lets the indicator flash "Saved" on Ctrl/Cmd+S even when the
* autosave already landed (otherwise the shortcut is silent then). */
readonly flushCount: number
}
/**
* Two-stage pipeline per draft key. The debouncer collapses keystroke
* bursts (1500ms reset, 10000ms ceiling so sustained typing still flushes
* within 10s). When it fires, `opts` goes to the coalescing runner, which
* keeps at most one POST in flight per key plus one "latest" follow-up —
* newer submissions replace any prior pending, so the server never sees
* stale-then-fresh out of order.
*
* Imperative awaits (delete-then-refetch) MUST NOT rely on `save()`'s
* promise: it resolves when the work is queued, not when the POST lands.
* Use the `immediate` bypass for those.
*/
const debouncer = createDebouncerByKey({ debounceMs: 1500, maxDebounceMs: 10000 })
const runner = createCoalescingKeyedRunner()
/**
* "Enable auto-save" preference (AutosaveIndicator popover toggle).
* Browser-wide and persisted. While off, `auto: true` saves and the
* unload keepalive flush are suppressed — nothing leaves the tab except
* explicit saves (latest opts still park in `pendingSaveOpts` for flush).
*/
const AUTOSAVE_ENABLED_LS_KEY = 'userDraftAutosaveEnabled'
function readAutosaveEnabled(): boolean {
try {
return localStorage.getItem(AUTOSAVE_ENABLED_LS_KEY) !== 'false'
} catch {
return true
}
}
let autosaveEnabledState = $state(readAutosaveEnabled())
/**
* Latest unconfirmed `save` opts per draft key. The unload flush fires a
* `keepalive` POST for each entry. Cleared by `postSave` on success, but
* only when the entry is still the same object the success was for — a
* newer `save()` during the in-flight POST must survive for the next round.
*/
const pendingSaveOpts = new Map<string, UserDraftDbSyncerSaveOpts>()
/**
* Keys whose saves are HARD-blocked: while editing another user's loaded draft
* the foreign value must never reach the server through ANY path (reactive
* mirror, explicit flush, the pagehide keepalive flush). The value is the
* "blocked save attempted" callback — the first such attempt is the user's
* first edit, which the overlay UI turns into an "overwrite?" prompt.
*/
const syncLocked = new Map<string, (() => void) | undefined>()
/**
* Conflict snapshots, populated when the server rejects a save (row
* `created_at` newer than our `last_sync`). Read via `getConflict(query)`
* to drive the resolution modal.
*/
const conflicts = new SvelteMap<string, DraftConflictInfo>()
/**
* Per-key listeners fired when a save lands at a path other than the key's:
* the item was moved while the editor was open, and the server put the save
* where the move took its drafts. The editor follows too (see `onRelocated`).
*/
const relocationListeners = new Map<string, Set<(newPath: string) => void>>()
/**
* Draft keys whose last save threw (network / 5xx) → extracted error
* message. Cleared on the next success. Drives the AutosaveIndicator's
* "Save failed" label so a silent failure can't masquerade as "Saved".
* Conflicts are tracked separately and don't populate this map.
*/
const failures = new SvelteMap<string, string>()
/**
* Reactive per-key counter bumped every time `flush()` completes — even
* when there was nothing to flush. See `UserDraftStateHandle.flushCount`.
*/
const flushes = new SvelteMap<string, number>()
/**
* Per-key listeners fired when a save for that key LANDS on the server
* (`status === 'saved'` with a non-null value — the draft now exists
* server-side). Distinct from `save()` resolving, which only means the work
* was queued. Used to defer the `?new_draft` URL strip until the first
* autosave is confirmed (see `stripNewDraftFlagOnSave`).
*/
const saveListeners = new Map<string, Set<() => void>>()
/**
* Global listeners fired whenever ANY draft write lands on the server —
* upserts and deletes alike. This is the invalidation hook for caches keyed
* on persisted draft state (the chat diff snapshot): the moment a save
* commits, the affected item can be marked stale without polling.
*/
type DraftSavedEvent = { workspace: string; itemKind: UserDraftItemKind; path: string }
const anySavedListeners = new Set<(event: DraftSavedEvent) => void>()
function notifyAnySaved(event: DraftSavedEvent): void {
for (const listener of [...anySavedListeners]) {
try {
listener(event)
} catch (e) {
console.error('UserDraftDbSyncer.onAnySaved listener threw', e)
}
}
}
/**
* Best-effort error → readable string. The generated client wraps HTTP
* failures as `ApiError` (`body` / `statusText`); raw fetch errors are a
* plain `Error`. Falls back to `String(e)` to avoid `[object Object]`.
*/
function formatSaveError(e: unknown): string {
if (e == null) return 'Unknown error'
if (typeof e === 'string') return e
const obj = e as Record<string, any>
const body = obj.body
if (typeof body === 'string' && body) return body
if (body && typeof body === 'object') {
const inner = body.error?.message ?? body.message ?? body.error
if (typeof inner === 'string' && inner) return inner
}
if (typeof obj.message === 'string' && obj.message) return obj.message
if (typeof obj.statusText === 'string' && obj.statusText) return obj.statusText
return String(e)
}
async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
const key = draftKey(opts.workspace, opts.itemKind, opts.path)
const lastSync = getLastSyncEntry(key)?.lastSync
try {
const resp = await DraftService.updateDraft({
workspace: opts.workspace,
kind: opts.itemKind,
path: opts.path,
requestBody: {
value: opts.value as any,
// Force-saves skip the conflict check; autosaves attach
// `last_sync` so the server can reject stale writes. Omitting
// `last_sync` (first-ever save) hits the backend's "treat as
// fresh" branch.
last_sync: opts.force ? undefined : lastSync,
force: opts.force ?? false
}
})
if (resp.status === 'conflict') {
// Someone advanced the row past our `last_sync`. Park the
// snapshot for the UI; do NOT touch `lastSync` — the next save
// retries from the same baseline so the conflict persists until
// resolved.
conflicts.set(key, {
serverTimestamp: resp.current_timestamp,
localLastSync: lastSync ?? null
})
return
}
// resp.status === 'saved' — advance lastSync (or drop on delete).
if (opts.value === null) {
clearLastSync(opts.workspace, opts.itemKind, opts.path)
} else {
setLastSync(opts.workspace, opts.itemKind, opts.path, resp.current_timestamp)
}
// postSave is the only place a draft's server-side existence changes,
// so it's the single source for the list pages' `*` hint
// (value !== null → exists). Every delete path clears the hint for
// free instead of maintaining a separate source of truth.
setLocalDraftHint(opts.workspace, opts.itemKind, opts.path, opts.value !== null)
conflicts.delete(key)
failures.delete(key)
// Clear pending only if it's still the opts we just saved — a
// newer `save()` that arrived during the POST replaces the entry
// and must survive for the next flush / debouncer round.
if (pendingSaveOpts.get(key) === opts) pendingSaveOpts.delete(key)
// Notify `onSaved` subscribers only for a real persisted draft, never a
// delete: stripping `?new_draft` after a `value: null` save would point a
// refresh at a row that no longer exists.
if (opts.value !== null) {
const listeners = saveListeners.get(key)
if (listeners) for (const l of [...listeners]) l()
}
// Global subscribers hear deletes too — a removed row invalidates
// cached state the same way an upsert does. Listener errors must never
// make a committed save read as failed.
notifyAnySaved({ workspace: opts.workspace, itemKind: opts.itemKind, path: opts.path })
// The item had moved and the write landed where its drafts went — a discard
// included, since the editor that sent it is on a path the item has left. Last,
// so the editor that reacts (by leaving this path) sees a settled key.
if (resp.path && resp.path !== opts.path) {
const listeners = relocationListeners.get(key)
if (listeners) for (const l of [...listeners]) l(resp.path)
}
} catch (e) {
console.error('UserDraftDbSyncer.save failed', e)
// Leave pending opts in place so the next attempt retries the same
// payload — we don't pretend the edit landed when it didn't.
failures.set(key, formatSaveError(e))
}
}
/** Keys whose `lastSync` was made stale by a `pagehide` keepalive flush
* (the POST advances `created_at` but its response is unreadable).
* Consumed by the `pageshow` handler: on a bfcache restore the SAME
* document comes back alive, and a save carrying the pre-flush
* `last_sync` would conflict against the user's own keepalive write. */
const staleSyncAfterHideFlush = new Set<string>()
/**
* True-unload flush on `pagehide`. Uses `keepalive` so the request commits
* after the JS context is torn down (response discarded). Bypasses the
* debouncer/runner — both are async-scheduled and won't run post-hide. The
* keepalive body is capped (~64KB in Chrome); oversized payloads are
* rejected and lost (logged, still better than dropping every pending
* save). The POST advances `created_at` but we can't read it to update
* `lastSync` — fine here, the next mount reseeds via `recordRemoteSync`.
*/
function flushOnPageHide(): void {
if (pendingSaveOpts.size === 0) return
for (const [key, opts] of pendingSaveOpts) {
// Editing another user's loaded draft: never flush the foreign value.
if (syncLocked.has(key)) continue
// Auto-save off: page-editor opts are dropped with the page;
// drawer-kind pendings (no `canBeDisabled`) still flush.
if (!autosaveEnabledState && opts.auto && opts.canBeDisabled) continue
debouncer.cancel(key)
try {
// `encodeURI` (not `encodeURIComponent`), mirroring the
// generated client, so slashes in the path pass through.
const url =
OpenAPI.BASE +
`/w/${encodeURI(opts.workspace)}` +
`/drafts/update/${encodeURI(opts.itemKind)}` +
`/${encodeURI(opts.path)}`
const lastSync = getLastSyncEntry(key)?.lastSync
void fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
value: opts.value,
last_sync: opts.force ? undefined : lastSync,
force: opts.force ?? false
}),
keepalive: true
}).catch((e) => {
console.error('UserDraftDbSyncer: keepalive flush failed', e)
})
// POST advanced the row past `lastSync` and we can't read the
// response — mark the key so a bfcache restore drops it, and notify
// subscribers conservatively (this path bypasses postSave; on a
// bfcache restore a cache must not serve the pre-flush state).
staleSyncAfterHideFlush.add(key)
notifyAnySaved({ workspace: opts.workspace, itemKind: opts.itemKind, path: opts.path })
} catch (e) {
console.error('UserDraftDbSyncer: keepalive flush threw', e)
}
}
pendingSaveOpts.clear()
}
if (typeof document !== 'undefined') {
// Only `pagehide` means the document is truly going away. Do NOT use
// `visibilitychange → hidden`: it fires on every tab switch with the
// page surviving, where the debounced POST still fires normally — a
// flush there would spuriously conflict against the user's own write.
window.addEventListener('pagehide', flushOnPageHide)
// bfcache restore: drop the keepalive-stale entries so the next save
// omits `last_sync` (first-push). Safe — the server copy it overwrites
// is this document's own flush. Without this, that save is rejected as
// a conflict and the modal opens against the user's own write.
window.addEventListener('pageshow', (e: PageTransitionEvent) => {
if (!e.persisted) return
for (const key of staleSyncAfterHideFlush) {
lastSyncMap.delete(key)
}
staleSyncAfterHideFlush.clear()
})
}
/**
* Server-side persistence for `UserDraft`. Every write goes through the
* debouncer + coalescing runner above so autosave spam can't become one
* POST per keystroke or out-of-order writes under slow networks.
*
* Concurrency: every save attaches the per-tab `last_sync` (from
* `recordRemoteSync` or the prior success). The server rejects a stale
* `last_sync` and `postSave` surfaces a `DraftConflictInfo` for the modal.
*/
export const UserDraftDbSyncer = {
/**
* Reactive autosave-state handle for a draft. The key is captured at
* call time; if the consumer's `(workspace, itemKind, path)` can change,
* recompute the handle in a `$derived`.
*/
getState(query: UserDraftLastSyncQuery): UserDraftStateHandle {
const key = draftKey(query.workspace, query.itemKind, query.path)
return {
get state(): UserDraftSyncState {
if (runner.isRunning(key)) return 'saving'
if (debouncer.isPending(key)) return 'pending'
if (failures.has(key)) return 'failed'
return 'none'
},
get failureMessage(): string | undefined {
// Suppress while saving/pending: an in-flight retry must not
// show the stale message from the failure it's retrying.
if (runner.isRunning(key) || debouncer.isPending(key)) return undefined
return failures.get(key)
},
get flushCount(): number {
return flushes.get(key) ?? 0
}
}
},
async save(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
// Trim non-draft fields once, here, so the parked opts the unload
// keepalive flush replays carry the same payload as the live POST.
opts = { ...opts, value: sanitizeDraftValueForSave(opts.itemKind, opts.value) }
const key = draftKey(opts.workspace, opts.itemKind, opts.path)
// Hard lock (editing another user's loaded draft): block EVERY save path
// for this key — no parking, no POST. Notify the overlay so the first
// blocked attempt (the user's first edit) can prompt before overwriting.
if (syncLocked.has(key)) {
syncLocked.get(key)?.()
return
}
// Park the latest opts BEFORE the pipeline so the unload flush has
// something to send even if the page hides before the debouncer fires.
pendingSaveOpts.set(key, opts)
if (opts.immediate) {
// Drop the queued autosave — firing it after our POST would
// re-save the pre-delete value. `submitAndWait` displaces the
// runner's own pending task, so no `runner.cancel` needed.
debouncer.cancel(key)
try {
await runner.submitAndWait(key, () => postSave(opts))
} catch (e) {
// Displacement is not a failure: a newer save took our slot, so
// re-POSTing ours would undo it. Wait for the chain instead —
// callers await this to know the key is settled, not to know
// their own payload won.
if (!(e instanceof CoalescingDisplacedError)) throw e
await runner.settled(key)
}
return
}
// Auto-save off: opts stay parked (above) for an explicit flush but
// never schedule a POST. Only for handles that opted into the toggle.
if (opts.auto && opts.canBeDisabled && !autosaveEnabledState) return
// Optimistically light the list-page `*` so it tracks the editor's
// unsaved-changes banner without waiting for the debounced POST;
// postSave reconciles to the confirmed server state.
if (opts.value !== null) {
setLocalDraftHint(opts.workspace, opts.itemKind, opts.path, true)
}
debouncer.schedule(key, () => {
runner.submit(key, () => postSave(opts))
})
},
/** "Enable auto-save" preference — see the module-level doc. Turning it
* back ON re-schedules every parked draft so edits made while off catch
* up without waiting for the next keystroke. */
get autosaveEnabled(): boolean {
return autosaveEnabledState
},
set autosaveEnabled(enabled: boolean) {
autosaveEnabledState = enabled
try {
localStorage.setItem(AUTOSAVE_ENABLED_LS_KEY, String(enabled))
} catch {}
if (enabled) {
for (const [key, opts] of pendingSaveOpts) {
debouncer.schedule(key, () => {
runner.submit(key, () => postSave(opts))
})
}
}
},
/**
* Seed the per-tab `last_sync` after an editor reads a draft from the
* server. Pass the response's `draft_saved_at` so the next save sends a
* matching `last_sync`; pass `undefined` when no draft existed (next
* save omits `last_sync`, the backend's first-push branch).
*/
recordRemoteSync(query: UserDraftLastSyncQuery, draftSavedAt: string | undefined): void {
const key = draftKey(query.workspace, query.itemKind, query.path)
if (draftSavedAt) {
setLastSync(query.workspace, query.itemKind, query.path, draftSavedAt)
} else {
clearLastSync(query.workspace, query.itemKind, query.path)
}
// Back in sync with the server: clear any conflict / failure.
conflicts.delete(key)
failures.delete(key)
},
/**
* Hard-block every save for this key (editing another user's loaded draft).
* Cancels any in-flight/queued autosave and drops parked opts so a pending
* flush can't fire the user's own value either. `onBlockedAttempt` fires on
* each subsequent blocked save — the overlay uses it to detect the first
* edit. MUST pair with `unlockSync`.
*/
lockSync(query: UserDraftLastSyncQuery, onBlockedAttempt?: () => void): void {
const key = draftKey(query.workspace, query.itemKind, query.path)
syncLocked.set(key, onBlockedAttempt)
debouncer.cancel(key)
runner.cancel(key)
pendingSaveOpts.delete(key)
},
/** Release a `lockSync`; subsequent saves go through normally. */
unlockSync(query: UserDraftLastSyncQuery): void {
syncLocked.delete(draftKey(query.workspace, query.itemKind, query.path))
},
/**
* Subscribe to CONFIRMED, non-delete saves for a draft key — fired after
* the POST lands on the server, not when `save()` queues it. Returns an
* unsubscribe. Backs `stripNewDraftFlagOnSave`.
*/
onSaved(query: UserDraftLastSyncQuery, listener: () => void): () => void {
const key = draftKey(query.workspace, query.itemKind, query.path)
let set = saveListeners.get(key)
if (!set) {
set = new Set()
saveListeners.set(key, set)
}
set.add(listener)
return () => {
const s = saveListeners.get(key)
if (!s) return
s.delete(listener)
if (s.size === 0) saveListeners.delete(key)
}
},
/**
* Fires when any draft write lands on the server — upserts AND deletes,
* every workspace and key. For caches over persisted draft state that
* must invalidate the affected item the moment a write commits.
*/
onAnySaved(listener: (event: DraftSavedEvent) => void): () => void {
anySavedListeners.add(listener)
return () => {
anySavedListeners.delete(listener)
}
},
/** Reactive conflict snapshot (if any) for a draft. */
getConflict(query: UserDraftLastSyncQuery): {
readonly conflict: DraftConflictInfo | undefined
} {
const key = draftKey(query.workspace, query.itemKind, query.path)
return {
get conflict() {
return conflicts.get(key)
}
}
},
/**
* Clear the conflict snapshot. `recordRemoteSync` does this implicitly
* on a fresh read, so the only standalone use is "dismiss without
* resolving".
*/
clearConflict(query: UserDraftLastSyncQuery): void {
conflicts.delete(draftKey(query.workspace, query.itemKind, query.path))
},
/**
* Subscribe to saves for a draft key that landed at another path: the item
* was moved while this editor was open. Fired with the path the save landed
* at, after the save is fully accounted for. Returns an
* unsubscribe.
*/
onRelocated(query: UserDraftLastSyncQuery, listener: (newPath: string) => void): () => void {
const key = draftKey(query.workspace, query.itemKind, query.path)
let set = relocationListeners.get(key)
if (!set) {
set = new Set()
relocationListeners.set(key, set)
}
set.add(listener)
return () => {
const s = relocationListeners.get(key)
if (!s) return
s.delete(listener)
if (s.size === 0) relocationListeners.delete(key)
}
},
/**
* Force-save: bypass the `last_sync` check and overwrite the server row
* (conflict modal's "Overwrite the remote"). Resolves once the key's save
* chain drains — see `immediate`; resolution means the chain settled, not
* that this force payload won (a later save can displace it). Callers
* `await` before navigating / refetching.
*/
async overwrite(opts: Omit<UserDraftDbSyncerSaveOpts, 'force'>): Promise<void> {
await this.save({ ...opts, immediate: true, force: true })
},
/**
* Flush the draft's queued autosave NOW (explicit Ctrl/Cmd+S). Re-submits
* the parked opts with `immediate: true` and resolves once the key's save
* chain drains (see `immediate` — the parked payload may be displaced by a
* later save carrying newer state), so callers can `await flush(...); show
* "Saved"`.
*
* No-op when nothing is pending. "No pending" does NOT mean "nothing to
* save" — Monaco may hold unmaterialized text; flush the editor
* (`Editor.flushPendingChanges()`) and await `tick()` first so its
* bind:code reaches our save() before this.
*
* `honorAutosaveToggle` makes the flush respect the "Enable auto-save"
* preference: a toggle-aware autosave (`auto` + `canBeDisabled`) stays
* parked while auto-save is off, so the edit is NOT persisted. The editor's
* unmount uses it (leaving with auto-save off must not silently save —
* the UnsavedConfirmationModal warns instead); explicit Ctrl/Cmd+S omits it
* and always saves.
*/
async flush(
query: UserDraftLastSyncQuery,
opts?: { honorAutosaveToggle?: boolean }
): Promise<void> {
const key = draftKey(query.workspace, query.itemKind, query.path)
try {
const parked = pendingSaveOpts.get(key)
if (!parked) return
if (opts?.honorAutosaveToggle && !autosaveEnabledState && parked.auto && parked.canBeDisabled)
return
await this.save({ ...parked, immediate: true })
} finally {
// Signal the indicator even on the no-op path so Ctrl/Cmd+S
// shows "Saved" even when the autosave already landed.
flushes.set(key, (flushes.get(key) ?? 0) + 1)
}
},
/**
* Whether this draft has content edits parked but unsaved because auto-save
* is off — the signal the full-page editors' UnsavedConfirmationModal uses
* to warn before leaving. True only when auto-save is disabled AND a
* toggle-aware (`auto` + `canBeDisabled`) write carrying content is parked;
* a parked delete (`value: null` from deploy / discard / reset) is not
* unsaved content. Read imperatively (e.g. in `beforeNavigate`), not
* reactively.
*/
hasUnsavedDisabledChanges(query: UserDraftLastSyncQuery): boolean {
if (autosaveEnabledState) return false
const parked = pendingSaveOpts.get(draftKey(query.workspace, query.itemKind, query.path))
return !!parked && parked.auto === true && parked.canBeDisabled === true && parked.value != null
},
/**
* Drop a draft's parked-but-unsaved autosave WITHOUT POSTing — the user
* chose to discard the auto-save-off edits on leave. Also cancels any
* queued debounce so turning auto-save back on can't resurrect them
* (the `autosaveEnabled` setter re-schedules every parked entry).
*/
dropPending(query: UserDraftLastSyncQuery): void {
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<void> {
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)
}
}