fix: harden async task and habit mutations
This commit is contained in:
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user