feat: add immediate-save bypass that cancels pending debouncer + runner tasks

This commit is contained in:
Diego Imbert
2026-06-04 15:32:36 +02:00
parent 17cacd67d2
commit 9f80974e25
6 changed files with 120 additions and 21 deletions
+79 -17
View File
@@ -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<unknown>
export type CoalescingTask<T = unknown> = () => T | Promise<T>
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<T>(key: string, fn: CoalescingTask<T>): Promise<T>
/** 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<string, Entry>()
async function chain(key: string, first: CoalescingTask): Promise<void> {
let current: CoalescingTask | undefined = first
async function chain(key: string, first: PendingTask): Promise<void> {
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<T>(key: string, fn: CoalescingTask<T>): Promise<T> {
return new Promise<T>((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 }
}
@@ -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 })
@@ -93,7 +93,8 @@
workspace: $workspaceStore!,
itemKind: 'flow',
path,
value: null
value: null,
immediate: true
})
} else {
await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path })
@@ -112,7 +112,8 @@
workspace: $workspaceStore!,
itemKind: 'script',
path,
value: null
value: null,
immediate: true
})
} else {
await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path })
+14 -1
View File
@@ -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 }
}
@@ -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<void> {
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))
})