diff --git a/frontend/src/lib/coalescingRunner.ts b/frontend/src/lib/coalescingRunner.ts index 555912a18c..04ad14bf5b 100644 --- a/frontend/src/lib/coalescingRunner.ts +++ b/frontend/src/lib/coalescingRunner.ts @@ -10,18 +10,49 @@ * Use to collapse bursts of "save the latest version" calls down to * exactly two runs: the one in flight plus the most recent. */ -export type CoalescingTask = () => unknown | Promise +export type CoalescingTask = () => T | Promise -export type CoalescingKeyedRunner = { - /** Schedule `fn` under `key` per the policy above. Synchronous; `fn` - * is invoked on the current tick when the key is idle. */ - submit(key: string, fn: CoalescingTask): void +/** Thrown into the rejection of a `submitAndWait` promise (and a + * `cancel`-dropped pending task) when the task is discarded before it + * had a chance to run. Fire-and-forget `submit` callers don't see this + * — only awaiters do. */ +export class CoalescingDisplacedError extends Error { + constructor() { + super('coalescingRunner: pending task displaced before it could run') + this.name = 'CoalescingDisplacedError' + } } -type Entry = { pending: CoalescingTask | undefined } +export type CoalescingKeyedRunner = { + /** Fire-and-forget. Schedule `fn` under `key` per the policy above. + * Synchronous; `fn` is invoked on the current tick when the key is + * idle. If a previously-submitted task is still pending for this + * key, it is silently dropped. */ + submit(key: string, fn: CoalescingTask): void + /** Same scheduling as `submit`, but returns a promise that resolves + * with `fn`'s return value when `fn` actually runs, rejects with + * `fn`'s throw if `fn` fails, or rejects with `CoalescingDisplacedError` + * if this submission is dropped (by `cancel`, or by a later + * `submit` / `submitAndWait` for the same key) before it runs. */ + submitAndWait(key: string, fn: CoalescingTask): Promise + /** Drop the pending (queued) task for `key` without running it. + * Returns true if there was something to cancel. Does NOT affect + * any task currently in flight — there's no way to abort it. If + * the dropped task was submitted via `submitAndWait`, its promise + * rejects with `CoalescingDisplacedError`. */ + cancel(key: string): boolean +} + +type PendingTask = { + fn: CoalescingTask + resolve?: (value: unknown) => void + reject?: (reason: unknown) => void +} + +type Entry = { pending: PendingTask | undefined } /** - * + * * @example * const runner = createCoalescingKeyedRunner() * // f, g, h are async functions @@ -34,15 +65,20 @@ type Entry = { pending: CoalescingTask | undefined } export function createCoalescingKeyedRunner(): CoalescingKeyedRunner { const state = new Map() - async function chain(key: string, first: CoalescingTask): Promise { - let current: CoalescingTask | undefined = first + async function chain(key: string, first: PendingTask): Promise { + let current: PendingTask | undefined = first while (current) { try { - await current() + const result = await current.fn() + current.resolve?.(result) } catch (e) { // Don't kill the chain on a task failure — bursty callers - // rely on later submissions still running. - console.error('coalescingRunner: task failed', e) + // rely on later submissions still running. submitAndWait + // callers see the error via their promise; fire-and-forget + // submit callers get a console.error so the failure isn't + // silently swallowed. + if (current.reject) current.reject(e) + else console.error('coalescingRunner: task failed', e) } const entry = state.get(key)! current = entry.pending @@ -51,16 +87,42 @@ export function createCoalescingKeyedRunner(): CoalescingKeyedRunner { state.delete(key) } - function submit(key: string, fn: CoalescingTask): void { + /** Set `task` as the pending entry for `key`, displacing whatever + * was there (and rejecting its promise if it had one). If the key + * is idle, set up the entry and start the chain. */ + function setOrDisplace(key: string, task: PendingTask): void { const entry = state.get(key) if (entry) { - // Drop whatever was queued — only the latest submission matters. - entry.pending = fn + entry.pending?.reject?.(new CoalescingDisplacedError()) + entry.pending = task return } state.set(key, { pending: undefined }) - void chain(key, fn) + void chain(key, task) } - return { submit } + function submit(key: string, fn: CoalescingTask): void { + setOrDisplace(key, { fn }) + } + + function submitAndWait(key: string, fn: CoalescingTask): Promise { + return new Promise((resolve, reject) => { + setOrDisplace(key, { + fn: fn as CoalescingTask, + resolve: resolve as (v: unknown) => void, + reject + }) + }) + } + + function cancel(key: string): boolean { + const entry = state.get(key) + if (!entry?.pending) return false + const dropped = entry.pending + entry.pending = undefined + dropped.reject?.(new CoalescingDisplacedError()) + return true + } + + return { submit, submitAndWait, cancel } } diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index a3b00292f7..cf4915753e 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -81,7 +81,8 @@ workspace: $workspaceStore ?? '', itemKind: app.raw_app ? 'raw_app' : 'app', path, - value: null + value: null, + immediate: true }) } else { await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index e83ed43985..48b05e2556 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -93,7 +93,8 @@ workspace: $workspaceStore!, itemKind: 'flow', path, - value: null + value: null, + immediate: true }) } else { await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path }) diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 9eb295a621..28f8878bc3 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -112,7 +112,8 @@ workspace: $workspaceStore!, itemKind: 'script', path, - value: null + value: null, + immediate: true }) } else { await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path }) diff --git a/frontend/src/lib/debouncerByKey.ts b/frontend/src/lib/debouncerByKey.ts index f1b457ce3c..8d6f1272e1 100644 --- a/frontend/src/lib/debouncerByKey.ts +++ b/frontend/src/lib/debouncerByKey.ts @@ -21,6 +21,11 @@ export type DebouncerByKey = { /** Replace any pending task under `key` with `fn`, set/extend the * timer to `min(now + debounceMs, chainStart + maxDebounceMs)`. */ schedule(key: string, fn: DebouncedTask): void + /** Clear the timer and drop the pending task for `key` without + * running it. Returns true if there was something to cancel. Use + * to hand control of a key over to an imperative path (e.g. an + * immediate save that supersedes the queued autosave). */ + cancel(key: string): boolean } type Entry = { @@ -66,5 +71,13 @@ export function createDebouncerByKey(opts: { entries.set(key, { timer, task: fn, chainStart }) } - return { schedule } + function cancel(key: string): boolean { + const existing = entries.get(key) + if (!existing) return false + clearTimeout(existing.timer) + entries.delete(key) + return true + } + + return { schedule, cancel } } diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index fc62192f16..5aae733911 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -74,6 +74,15 @@ export type UserDraftDbSyncerSaveOpts = { /** `null` signals a delete — the server removes the row under the same * conflict rules as an upsert. */ value: unknown | null + /** Bypass the autosave debouncer for THIS save. Cancels any pending + * debouncer task for the same key (the queued autosave would + * otherwise overwrite what we're about to send), routes through the + * coalescing runner so ordering against any in-flight POST is + * preserved, and the returned promise resolves only after the POST + * actually lands. Use for `await save(...); then-read-the-server` + * flows (table-row delete, etc.) where a fire-and-forget save would + * race the next read. */ + immediate?: boolean } export type UserDraftLastSyncQuery = { @@ -157,6 +166,18 @@ export const UserDraftDbSyncer = { async save(opts: UserDraftDbSyncerSaveOpts): Promise { const key = draftKey(opts.workspace, opts.itemKind, opts.path) + if (opts.immediate) { + // Drop the queued autosave (if any) — letting it fire after + // our POST would re-save the pre-delete value. The runner's + // own cancel is implicit in `submitAndWait`, which displaces + // any pending runner task with ours, but call it explicitly + // so a `submit` -> `cancel` -> `submitAndWait` sequence is + // observable in the runner's internal state for debugging. + debouncer.cancel(key) + runner.cancel(key) + await runner.submitAndWait(key, () => postSave(opts)) + return + } debouncer.schedule(key, () => { runner.submit(key, () => postSave(opts)) })