fix(web): await replacement refresh coverage
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
29
apps/web_v1/scripts/recovery-refresh.test.mjs
Normal file
29
apps/web_v1/scripts/recovery-refresh.test.mjs
Normal 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'])
|
||||
})
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
|
||||
4
apps/web_v1/src/app/recovery-refresh.ts
Normal file
4
apps/web_v1/src/app/recovery-refresh.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export async function runRecoveryRefresh(refresh: () => Promise<void>, onSuccess: () => void) {
|
||||
await refresh()
|
||||
onSuccess()
|
||||
}
|
||||
Reference in New Issue
Block a user