fix: harden async task and habit mutations
ci / gitleaks (push) Successful in 1m50s
ci / docker (push) Successful in 9m26s

This commit is contained in:
2026-09-19 10:00:23 +08:00
parent 0baedbc99e
commit cec0a4418c
8 changed files with 1593 additions and 164 deletions
+273 -8
View File
@@ -323,7 +323,268 @@ export async function runLatestRequest<T>(
return committed
}
type MutationOwnership = { current: () => boolean }
type LatestMutationCallbacks<T> = {
success?: (value: T, ownership: MutationOwnership) => void | Promise<void>
error?: (reason: unknown, ownership: MutationOwnership) => void | Promise<void>
settled?: (result: { ok: true; value: T } | { ok: false; reason: unknown }, ownership: MutationOwnership) => void | Promise<void>
}
export function createKeyedLatestMutationQueue() {
type Entry = { generation: number; tail: Promise<void> }
const entries = new Map<string, Entry>()
return {
size: () => entries.size,
async run<T>(key: string, mutation: () => Promise<T>, callbacks: LatestMutationCallbacks<T> = {}) {
let entry = entries.get(key)
if (!entry) {
entry = { generation: 0, tail: Promise.resolve() }
entries.set(key, entry)
}
const generation = ++entry.generation
const ownership = { current: () => entries.get(key) === entry && entry.generation === generation }
const pending = entry.tail.catch(() => undefined).then(mutation)
const tail = pending.then(() => undefined, () => undefined)
entry.tail = tail
try {
const value = await pending
await callbacks.settled?.({ ok: true, value }, ownership)
if (!ownership.current()) return false
await callbacks.success?.(value, ownership)
return ownership.current()
} catch (reason) {
await callbacks.settled?.({ ok: false, reason }, ownership)
if (ownership.current()) await callbacks.error?.(reason, ownership)
return false
} finally {
if (entries.get(key) === entry && entry.generation === generation && entry.tail === tail) entries.delete(key)
}
},
}
}
export function createHabitMutationCoordinator<T>() {
type Entry = { generation: number; tail: Promise<void>; confirmed: T }
const entries = new Map<string, Entry>()
return {
size: () => entries.size,
async run(
key: string,
previous: T,
next: T,
mutation: () => Promise<unknown>,
callbacks: {
current?: () => boolean
reconcile?: (confirmed: T) => void | Promise<void>
success?: (ownership: MutationOwnership) => void | Promise<void>
error?: (rollback: T, reason: unknown, ownership: MutationOwnership) => void | Promise<void>
} = {},
) {
let entry = entries.get(key)
if (!entry) {
entry = { generation: 0, tail: Promise.resolve(), confirmed: previous }
entries.set(key, entry)
}
const generation = ++entry.generation
const ownership = {
current: () => entries.get(key) === entry && entry.generation === generation && callbacks.current?.() !== false,
}
const pending = entry.tail.catch(() => undefined).then(mutation)
const tail = pending.then(() => undefined, () => undefined)
entry.tail = tail
let result: { ok: true } | { ok: false; reason: unknown }
try {
await pending
result = { ok: true }
} catch (reason) {
result = { ok: false, reason }
}
try {
if (result.ok) {
entry.confirmed = next
await callbacks.reconcile?.(entry.confirmed)
if (!ownership.current()) return false
await callbacks.success?.(ownership)
return ownership.current()
}
await callbacks.reconcile?.(entry.confirmed)
if (ownership.current()) await callbacks.error?.(entry.confirmed, result.reason, ownership)
return false
} finally {
if (entries.get(key) === entry && entry.generation === generation && entry.tail === tail) entries.delete(key)
}
},
}
}
export function createTaskToggleCoordinator<T extends { completed: boolean; version: number }>() {
type Entry = { tail: Promise<void>; intendedCompleted: boolean; confirmed: T; generation: number }
const entries = new Map<string, Entry>()
return {
size: () => entries.size,
async toggle(
key: string,
initial: T,
mutation: (payload: { completed: boolean; version: number }) => Promise<T>,
callbacks: {
current?: () => boolean
beforeReconcile?: (value: T, ownership: MutationOwnership) => void | Promise<void>
reconcile?: (confirmed: T, ownership: MutationOwnership) => void | Promise<void>
success?: (value: T, ownership: MutationOwnership) => void | Promise<void>
error?: (confirmed: T, reason: unknown, ownership: MutationOwnership) => void | Promise<void>
} = {},
) {
let entry = entries.get(key)
if (!entry) {
entry = { tail: Promise.resolve(), intendedCompleted: initial.completed, confirmed: initial, generation: 0 }
entries.set(key, entry)
}
entry.intendedCompleted = !entry.intendedCompleted
const intendedCompleted = entry.intendedCompleted
const generation = ++entry.generation
const ownership = { current: () => entry!.generation === generation && callbacks.current?.() !== false }
const pending = entry.tail.then(async () => {
const value = await mutation({ completed: intendedCompleted, version: entry!.confirmed.version })
entry!.confirmed = value
return value
})
entry.tail = pending.then(() => undefined, () => undefined)
try {
let value: T
try {
value = await pending
} catch (reason) {
await callbacks.reconcile?.(entry.confirmed, ownership)
if (ownership.current()) await callbacks.error?.(entry.confirmed, reason, ownership)
return false
}
if (ownership.current()) await callbacks.beforeReconcile?.(value, ownership)
await callbacks.reconcile?.(value, ownership)
if (!ownership.current()) return false
await callbacks.success?.(value, ownership)
return ownership.current()
} finally {
if (entry.generation === generation && entries.get(key) === entry) entries.delete(key)
}
},
}
}
type CurrentViewReconciliationOptions<TContext> = {
capture: () => TContext
isTaskBacked: (context: TContext) => boolean
isTrash?: (context: TContext) => boolean
sameContext: (left: TContext, right: TContext) => boolean
loadTaskView: (context: TContext, ownership: MutationOwnership) => Promise<void>
loadTrash?: (ownership: MutationOwnership) => Promise<void>
affectsTrash?: boolean
affectsTaskView?: boolean
}
export async function reconcileCurrentTaskView<TContext>(options: CurrentViewReconciliationOptions<TContext>) {
const context = options.capture()
const ownership = { current: () => options.sameContext(context, options.capture()) }
if (options.affectsTaskView !== false && options.isTaskBacked(context)) {
await options.loadTaskView(context, ownership)
return ownership.current()
}
if (options.affectsTrash && options.isTrash?.(context) && options.loadTrash) {
await options.loadTrash(ownership)
return ownership.current()
}
return false
}
export function createTaskCompletionExitCoordinator(setExit: (key: string, active: boolean) => void) {
const tokens = new Map<string, object>()
const supersede = (key: string) => {
tokens.delete(key)
setExit(key, false)
}
const clearAll = () => {
const keys = [...tokens.keys()]
tokens.clear()
keys.forEach((key) => setExit(key, false))
}
const begin = (key: string, animate: boolean) => {
tokens.delete(key)
if (!animate) {
setExit(key, false)
return null
}
const token = {}
tokens.set(key, token)
setExit(key, true)
return token
}
const wait = async (key: string, token: object | null, waitForExit: () => Promise<void>) => {
if (!token) return
try {
await waitForExit()
} finally {
if (tokens.get(key) === token) {
tokens.delete(key)
setExit(key, false)
}
}
}
return {
size: () => tokens.size,
supersede,
clearAll,
begin,
wait,
async run(key: string, animate: boolean, waitForExit: () => Promise<void>) {
const token = begin(key, animate)
await wait(key, token, waitForExit)
},
}
}
type TaskToggleState = {
id: string
list_id: string
parent_id: string | null
title: string
description: string
priority: number
completed: boolean
completed_at: string | null
version: number
due_at: string | null
due_has_time: boolean
subtasks?: TaskToggleState[]
}
export function taskVersionedPatchPayload<T extends { version: number }, P extends object>(task: T, patch: P): P & { version: number } {
return { ...patch, version: task.version }
}
export function mergeTaskToggleResponse<T extends TaskToggleState>(draft: T, response: TaskToggleState): T {
return {
...response,
list_id: draft.list_id,
parent_id: draft.parent_id,
title: draft.title,
description: draft.description,
priority: draft.priority,
due_at: draft.due_at,
due_has_time: draft.due_has_time,
subtasks: draft.subtasks,
} as T
}
type MutationSuccessCallback<T> = (value: T) => void | Promise<void>
type MutationReconciliationOptions = { affectsTrash?: boolean; affectsTaskView?: boolean }
export type MutationReconciler<TContext> = {
run<T>(
@@ -331,26 +592,27 @@ export type MutationReconciler<TContext> = {
onSuccess: (value: T) => void,
onError?: (reason: unknown) => void,
onCurrentSuccess?: MutationSuccessCallback<T>,
reconciliation?: MutationReconciliationOptions,
): Promise<boolean>
}
export function createMutationReconciler<TContext>(
currentContext: () => TContext,
sameContext: (left: TContext, right: TContext) => boolean,
refresh: () => Promise<unknown>,
reconcileCurrentView: (options?: MutationReconciliationOptions) => Promise<unknown>,
): MutationReconciler<TContext> {
let dirty = 0
let reconciled = 0
let refreshLoop: Promise<void> | null = null
const reconcile = (context: TContext) => {
const reconcile = (context: TContext, options?: MutationReconciliationOptions) => {
if (!sameContext(context, currentContext())) return Promise.resolve()
dirty += 1
if (!refreshLoop) {
refreshLoop = (async () => {
while (reconciled < dirty && sameContext(context, currentContext())) {
const target = dirty
await refresh()
await reconcileCurrentView(options)
if (!sameContext(context, currentContext())) break
reconciled = target
}
@@ -360,22 +622,25 @@ export function createMutationReconciler<TContext>(
}
return {
async run(mutation, onSuccess, onError, onCurrentSuccess) {
async run(mutation, onSuccess, onError, onCurrentSuccess, reconciliation) {
const context = currentContext()
let value: Awaited<ReturnType<typeof mutation>>
try {
value = await mutation()
} catch (reason) {
onError?.(reason)
if (sameContext(context, currentContext())) onError?.(reason)
return false
}
onSuccess(value)
if (sameContext(context, currentContext())) {
onSuccess(value)
const currentSuccess = onCurrentSuccess?.(value)
if (currentSuccess instanceof Promise) await currentSuccess
} else {
await reconcileCurrentView(reconciliation)
return true
}
await reconcile(context)
if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context)
await reconcile(context, reconciliation)
if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context, reconciliation)
return true
},
}