fix(web): await replacement refresh coverage

This commit is contained in:
2026-07-22 14:21:33 +08:00
parent 51c6fea343
commit 19f9e163e2
6 changed files with 186 additions and 26 deletions

View File

@@ -55,3 +55,35 @@ test('full and targeted refreshes supersede only when their coverage overlaps gl
assert.equal(full.signal.aborted, true)
assert.equal(newerProjectA.isCurrent(), true)
})
test('project A superseded by a failing full refresh is not considered covered', async () => {
const coordinator = createProjectRefreshCoordinator()
const projectA = coordinator.beginProject('project-a')
const full = coordinator.beginFull()
full.finish(false)
assert.equal(await projectA.waitForCoverage(), false)
})
test('only a successful replacement confirms coverage of a superseded project', async () => {
const coordinator = createProjectRefreshCoordinator()
const firstProjectA = coordinator.beginProject('project-a')
const newerProjectA = coordinator.beginProject('project-a')
newerProjectA.finish(true)
assert.equal(await firstProjectA.waitForCoverage(), true)
})
test('a successful full refresh covers every targeted request it superseded', async () => {
const coordinator = createProjectRefreshCoordinator()
const projectA = coordinator.beginProject('project-a')
const projectB = coordinator.beginProject('project-b')
const full = coordinator.beginFull()
full.finish(true)
assert.equal(await projectA.waitForCoverage(), true)
assert.equal(await projectB.waitForCoverage(), true)
})

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { runRecoveryRefresh } from '../src/app/recovery-refresh.ts'
test('a failed recovery never announces refreshed success', async () => {
const events = []
await assert.rejects(() => runRecoveryRefresh(
async () => {
events.push('refresh')
throw new Error('still stale')
},
() => events.push('success'),
), /still stale/)
assert.deepEqual(events, ['refresh'])
})
test('recovery announces success only after every requested refresh resolves', async () => {
const events = []
await runRecoveryRefresh(
async () => events.push('refresh'),
() => events.push('success'),
)
assert.deepEqual(events, ['refresh', 'success'])
})

View File

@@ -4,6 +4,7 @@ const requiredFiles = [
'src/app/App.tsx',
'src/app/mutation-refresh.ts',
'src/app/project-refresh-coordinator.ts',
'src/app/recovery-refresh.ts',
'src/pages/login.tsx',
'src/pages/workspace-body.tsx',
'src/pages/workspace-home.tsx',
@@ -33,6 +34,7 @@ const requiredFiles = [
'scripts/mutation-refresh.test.mjs',
'scripts/mutation-wiring.test.mjs',
'scripts/project-refresh-coordinator.test.mjs',
'scripts/recovery-refresh.test.mjs',
]
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)

View File

@@ -34,6 +34,7 @@ import {
mergePendingRefreshProjects,
type ProjectRefreshCoordinator,
} from './project-refresh-coordinator'
import { runRecoveryRefresh } from './recovery-refresh'
function App() {
const [screen, setScreen] = useState<Screen>('login')
@@ -81,20 +82,33 @@ function App() {
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) {
const request = refreshCoordinator.current!.beginFull()
const projectIDWhenStarted = activeProjectIDRef.current
const backendProjects = await fetchProjects(nextSession, request.signal)
const backendWorkspaces = await Promise.all(
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id, request.signal).then((workspace) => mapWorkspace(workspace, index))),
)
if (!request.isCurrent()) throw staleWorkspaceRefreshError()
setWorkspaces(backendWorkspaces)
const latestProjectID = activeProjectIDRef.current
const requestedProjectID = latestProjectID !== projectIDWhenStarted
? latestProjectID
: preferredProjectID ?? latestProjectID
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === requestedProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
selectActiveProject(nextProjectID)
setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current))
return backendWorkspaces
try {
const backendProjects = await fetchProjects(nextSession, request.signal)
const backendWorkspaces = await Promise.all(
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id, request.signal).then((workspace) => mapWorkspace(workspace, index))),
)
if (!request.isCurrent()) {
if (await request.waitForCoverage()) return workspaces
throw staleWorkspaceRefreshError()
}
setWorkspaces(backendWorkspaces)
const latestProjectID = activeProjectIDRef.current
const requestedProjectID = latestProjectID !== projectIDWhenStarted
? latestProjectID
: preferredProjectID ?? latestProjectID
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === requestedProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
selectActiveProject(nextProjectID)
request.finish(true)
setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current))
return backendWorkspaces
} catch (error) {
if (request.isCurrent()) {
request.finish(false)
} else if (await request.waitForCoverage()) {
return workspaces
}
throw error
}
}
async function refreshProjectWorkspaces(nextSession: ApiSession, projectIDs: string[]) {
@@ -105,13 +119,25 @@ function App() {
const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectID)
try {
const payload = await fetchProjectWorkspace(nextSession, projectID, request.signal)
if (!request.isCurrent()) return { projectID, status: 'superseded' as const }
if (!request.isCurrent()) {
const covered = await request.waitForCoverage()
return covered
? { projectID, status: 'covered' as const }
: { projectID, status: 'failed' as const, error: staleWorkspaceRefreshError() }
}
const refreshed = mapWorkspace(payload, workspaceIndex < 0 ? 0 : workspaceIndex)
setWorkspaces((current) => mergeRefreshedWorkspaces(current, [refreshed]))
request.finish(true)
setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current, [projectID]))
return { projectID, status: 'refreshed' as const, workspace: refreshed }
} catch (error) {
if (!request.isCurrent()) return { projectID, status: 'superseded' as const }
if (!request.isCurrent()) {
const covered = await request.waitForCoverage()
return covered
? { projectID, status: 'covered' as const }
: { projectID, status: 'failed' as const, error }
}
request.finish(false)
return { projectID, status: 'failed' as const, error }
}
}))
@@ -153,8 +179,10 @@ function App() {
const targets = [...pendingRefreshProjectIDs]
setRefreshRetrying(true)
try {
await refreshProjectWorkspaces(requireSession(), targets)
Message.success('数据已刷新')
await runRecoveryRefresh(
() => refreshProjectWorkspaces(requireSession(), targets).then(() => undefined),
() => Message.success('数据已刷新'),
)
} catch (error) {
Message.error(error instanceof Error ? error.message : '部分项目刷新失败,请稍后重试')
} finally {

View File

@@ -1,8 +1,21 @@
import { createWorkspaceRefreshGate } from './workspace-refresh-gate.ts'
import { createWorkspaceRefreshGate, type WorkspaceRefreshTicket } from './workspace-refresh-gate.ts'
export type CoordinatedRefreshTicket = WorkspaceRefreshTicket & {
finish: (succeeded: boolean) => void
waitForCoverage: () => Promise<boolean>
}
type RefreshState = {
ticket: CoordinatedRefreshTicket
resolve: (succeeded: boolean) => void
outcome: Promise<boolean>
}
export function createProjectRefreshCoordinator() {
const fullGate = createWorkspaceRefreshGate()
const projectGates = new Map<string, ReturnType<typeof createWorkspaceRefreshGate>>()
const projectStates = new Map<string, RefreshState>()
let fullState: RefreshState | null = null
function projectGate(projectID: string) {
const existing = projectGates.get(projectID)
@@ -12,18 +25,70 @@ export function createProjectRefreshCoordinator() {
return created
}
function stateFor(base: WorkspaceRefreshTicket): RefreshState {
let settle!: (succeeded: boolean) => void
let settled = false
const outcome = new Promise<boolean>((resolve) => {
settle = (succeeded) => {
if (settled) return
settled = true
resolve(succeeded)
}
})
return {
resolve: settle,
outcome,
ticket: {
...base,
finish: settle,
waitForCoverage: () => outcome,
},
}
}
function coveredBy(replaced: RefreshState | null | undefined, replacement: RefreshState) {
if (!replaced) return
void replacement.outcome.then(replaced.resolve)
}
return {
beginFull() {
for (const gate of projectGates.values()) gate.invalidate()
return fullGate.begin()
beginFull(): CoordinatedRefreshTicket {
const previousFull = fullState
const next = stateFor(fullGate.begin())
coveredBy(previousFull, next)
fullState = next
for (const [projectID, state] of projectStates) {
projectGates.get(projectID)?.invalidate()
coveredBy(state, next)
}
projectStates.clear()
return next.ticket
},
beginProject(projectID: string) {
fullGate.invalidate()
return projectGate(projectID).begin()
beginProject(projectID: string): CoordinatedRefreshTicket {
if (fullState) {
fullGate.invalidate()
fullState.resolve(false)
fullState = null
} else {
fullGate.invalidate()
}
const previous = projectStates.get(projectID)
const next = stateFor(projectGate(projectID).begin())
coveredBy(previous, next)
projectStates.set(projectID, next)
return next.ticket
},
invalidateAll() {
fullGate.invalidate()
for (const gate of projectGates.values()) gate.invalidate()
fullState?.resolve(false)
fullState = null
for (const [projectID, state] of projectStates) {
projectGates.get(projectID)?.invalidate()
state.resolve(false)
}
projectStates.clear()
projectGates.clear()
},
}

View File

@@ -0,0 +1,4 @@
export async function runRecoveryRefresh(refresh: () => Promise<void>, onSuccess: () => void) {
await refresh()
onSuccess()
}