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(full.signal.aborted, true)
|
||||||
assert.equal(newerProjectA.isCurrent(), 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/App.tsx',
|
||||||
'src/app/mutation-refresh.ts',
|
'src/app/mutation-refresh.ts',
|
||||||
'src/app/project-refresh-coordinator.ts',
|
'src/app/project-refresh-coordinator.ts',
|
||||||
|
'src/app/recovery-refresh.ts',
|
||||||
'src/pages/login.tsx',
|
'src/pages/login.tsx',
|
||||||
'src/pages/workspace-body.tsx',
|
'src/pages/workspace-body.tsx',
|
||||||
'src/pages/workspace-home.tsx',
|
'src/pages/workspace-home.tsx',
|
||||||
@@ -33,6 +34,7 @@ const requiredFiles = [
|
|||||||
'scripts/mutation-refresh.test.mjs',
|
'scripts/mutation-refresh.test.mjs',
|
||||||
'scripts/mutation-wiring.test.mjs',
|
'scripts/mutation-wiring.test.mjs',
|
||||||
'scripts/project-refresh-coordinator.test.mjs',
|
'scripts/project-refresh-coordinator.test.mjs',
|
||||||
|
'scripts/recovery-refresh.test.mjs',
|
||||||
]
|
]
|
||||||
|
|
||||||
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
|
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
mergePendingRefreshProjects,
|
mergePendingRefreshProjects,
|
||||||
type ProjectRefreshCoordinator,
|
type ProjectRefreshCoordinator,
|
||||||
} from './project-refresh-coordinator'
|
} from './project-refresh-coordinator'
|
||||||
|
import { runRecoveryRefresh } from './recovery-refresh'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [screen, setScreen] = useState<Screen>('login')
|
const [screen, setScreen] = useState<Screen>('login')
|
||||||
@@ -81,20 +82,33 @@ function App() {
|
|||||||
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) {
|
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) {
|
||||||
const request = refreshCoordinator.current!.beginFull()
|
const request = refreshCoordinator.current!.beginFull()
|
||||||
const projectIDWhenStarted = activeProjectIDRef.current
|
const projectIDWhenStarted = activeProjectIDRef.current
|
||||||
const backendProjects = await fetchProjects(nextSession, request.signal)
|
try {
|
||||||
const backendWorkspaces = await Promise.all(
|
const backendProjects = await fetchProjects(nextSession, request.signal)
|
||||||
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id, request.signal).then((workspace) => mapWorkspace(workspace, index))),
|
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)
|
if (!request.isCurrent()) {
|
||||||
const latestProjectID = activeProjectIDRef.current
|
if (await request.waitForCoverage()) return workspaces
|
||||||
const requestedProjectID = latestProjectID !== projectIDWhenStarted
|
throw staleWorkspaceRefreshError()
|
||||||
? latestProjectID
|
}
|
||||||
: preferredProjectID ?? latestProjectID
|
setWorkspaces(backendWorkspaces)
|
||||||
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === requestedProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
|
const latestProjectID = activeProjectIDRef.current
|
||||||
selectActiveProject(nextProjectID)
|
const requestedProjectID = latestProjectID !== projectIDWhenStarted
|
||||||
setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current))
|
? latestProjectID
|
||||||
return backendWorkspaces
|
: 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[]) {
|
async function refreshProjectWorkspaces(nextSession: ApiSession, projectIDs: string[]) {
|
||||||
@@ -105,13 +119,25 @@ function App() {
|
|||||||
const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectID)
|
const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectID)
|
||||||
try {
|
try {
|
||||||
const payload = await fetchProjectWorkspace(nextSession, projectID, request.signal)
|
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)
|
const refreshed = mapWorkspace(payload, workspaceIndex < 0 ? 0 : workspaceIndex)
|
||||||
setWorkspaces((current) => mergeRefreshedWorkspaces(current, [refreshed]))
|
setWorkspaces((current) => mergeRefreshedWorkspaces(current, [refreshed]))
|
||||||
|
request.finish(true)
|
||||||
setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current, [projectID]))
|
setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current, [projectID]))
|
||||||
return { projectID, status: 'refreshed' as const, workspace: refreshed }
|
return { projectID, status: 'refreshed' as const, workspace: refreshed }
|
||||||
} catch (error) {
|
} 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 }
|
return { projectID, status: 'failed' as const, error }
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -153,8 +179,10 @@ function App() {
|
|||||||
const targets = [...pendingRefreshProjectIDs]
|
const targets = [...pendingRefreshProjectIDs]
|
||||||
setRefreshRetrying(true)
|
setRefreshRetrying(true)
|
||||||
try {
|
try {
|
||||||
await refreshProjectWorkspaces(requireSession(), targets)
|
await runRecoveryRefresh(
|
||||||
Message.success('数据已刷新')
|
() => refreshProjectWorkspaces(requireSession(), targets).then(() => undefined),
|
||||||
|
() => Message.success('数据已刷新'),
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Message.error(error instanceof Error ? error.message : '部分项目刷新失败,请稍后重试')
|
Message.error(error instanceof Error ? error.message : '部分项目刷新失败,请稍后重试')
|
||||||
} finally {
|
} 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() {
|
export function createProjectRefreshCoordinator() {
|
||||||
const fullGate = createWorkspaceRefreshGate()
|
const fullGate = createWorkspaceRefreshGate()
|
||||||
const projectGates = new Map<string, ReturnType<typeof createWorkspaceRefreshGate>>()
|
const projectGates = new Map<string, ReturnType<typeof createWorkspaceRefreshGate>>()
|
||||||
|
const projectStates = new Map<string, RefreshState>()
|
||||||
|
let fullState: RefreshState | null = null
|
||||||
|
|
||||||
function projectGate(projectID: string) {
|
function projectGate(projectID: string) {
|
||||||
const existing = projectGates.get(projectID)
|
const existing = projectGates.get(projectID)
|
||||||
@@ -12,18 +25,70 @@ export function createProjectRefreshCoordinator() {
|
|||||||
return created
|
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 {
|
return {
|
||||||
beginFull() {
|
beginFull(): CoordinatedRefreshTicket {
|
||||||
for (const gate of projectGates.values()) gate.invalidate()
|
const previousFull = fullState
|
||||||
return fullGate.begin()
|
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) {
|
beginProject(projectID: string): CoordinatedRefreshTicket {
|
||||||
fullGate.invalidate()
|
if (fullState) {
|
||||||
return projectGate(projectID).begin()
|
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() {
|
invalidateAll() {
|
||||||
fullGate.invalidate()
|
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()
|
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