diff --git a/apps/web_v1/scripts/project-refresh-coordinator.test.mjs b/apps/web_v1/scripts/project-refresh-coordinator.test.mjs index 80acc0f..df099de 100644 --- a/apps/web_v1/scripts/project-refresh-coordinator.test.mjs +++ b/apps/web_v1/scripts/project-refresh-coordinator.test.mjs @@ -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) +}) diff --git a/apps/web_v1/scripts/recovery-refresh.test.mjs b/apps/web_v1/scripts/recovery-refresh.test.mjs new file mode 100644 index 0000000..f8c5345 --- /dev/null +++ b/apps/web_v1/scripts/recovery-refresh.test.mjs @@ -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']) +}) diff --git a/apps/web_v1/scripts/structure-check.mjs b/apps/web_v1/scripts/structure-check.mjs index 854b2da..28bdc78 100644 --- a/apps/web_v1/scripts/structure-check.mjs +++ b/apps/web_v1/scripts/structure-check.mjs @@ -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}`) diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index 47aef22..0d7e1e3 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -34,6 +34,7 @@ import { mergePendingRefreshProjects, type ProjectRefreshCoordinator, } from './project-refresh-coordinator' +import { runRecoveryRefresh } from './recovery-refresh' function App() { const [screen, setScreen] = useState('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 { diff --git a/apps/web_v1/src/app/project-refresh-coordinator.ts b/apps/web_v1/src/app/project-refresh-coordinator.ts index 64a4ad2..4db6e31 100644 --- a/apps/web_v1/src/app/project-refresh-coordinator.ts +++ b/apps/web_v1/src/app/project-refresh-coordinator.ts @@ -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 +} + +type RefreshState = { + ticket: CoordinatedRefreshTicket + resolve: (succeeded: boolean) => void + outcome: Promise +} export function createProjectRefreshCoordinator() { const fullGate = createWorkspaceRefreshGate() const projectGates = new Map>() + const projectStates = new Map() + 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((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() }, } diff --git a/apps/web_v1/src/app/recovery-refresh.ts b/apps/web_v1/src/app/recovery-refresh.ts new file mode 100644 index 0000000..d869035 --- /dev/null +++ b/apps/web_v1/src/app/recovery-refresh.ts @@ -0,0 +1,4 @@ +export async function runRecoveryRefresh(refresh: () => Promise, onSuccess: () => void) { + await refresh() + onSuccess() +}