From d5aac0924ecddadc240bbd8f1c7bddce14c02f95 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:02:05 +0800 Subject: [PATCH] fix(client): settle table paging cancellation and errors --- .../ResultSet/viewTablePagingFlow.test.ts | 46 +++++++++++++++++-- .../src/components/ViewTable/index.tsx | 2 +- .../src/hooks/useViewTablePaging.ts | 23 ++++++++-- .../src/hooks/viewTablePagingModel.ts | 37 +++++++++++++-- 4 files changed, 97 insertions(+), 11 deletions(-) diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSet/viewTablePagingFlow.test.ts b/chat2db-community-client/src/blocks/SearchResult/components/ResultSet/viewTablePagingFlow.test.ts index 3121c8507b..e2cabedfc1 100644 --- a/chat2db-community-client/src/blocks/SearchResult/components/ResultSet/viewTablePagingFlow.test.ts +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSet/viewTablePagingFlow.test.ts @@ -208,11 +208,46 @@ test('web table browse retains execution params across consecutive page requests assert.equal(secondPage.executeSqlParams?.pageSize, 5000); }); -test('a cancelled table browse never publishes its buffered partial rows', () => { - const params = { sql: BASE_SQL, dataSourceId: 42, pageNo: 1, pageSize: 50_000 }; +test('a failed table browse page preserves the confirmed result and reports the backend error', () => { + const confirmedResult = result({ + uuid: 'confirmed-result', + dataList: [[{ value: 'confirmed-row' }]] as any, + pageNo: 1, + }); + const params = { sql: BASE_SQL, dataSourceId: 42, pageNo: 2, pageSize: 1000 }; const requestSequence = 8; + + const transition = reduceViewTablePagingEvent( + createViewTablePagingState(requestSequence, params, confirmedResult), + event( + 'resultFinished', + result({ + success: false, + message: 'Unknown column status', + dataList: [], + pageNo: 2, + }), + ), + requestSequence, + ); + + assert.equal(transition.completedResult, undefined, 'a failed page must not replace confirmed rows'); + assert.equal(transition.errorMessage, 'Unknown column status'); + assert.equal(transition.state.errorMessage, 'Unknown column status'); + assert.deepEqual(confirmedResult.dataList, [[{ value: 'confirmed-row' }]]); +}); + +test('a cancelled table browse republishes the confirmed page without buffered partial rows', () => { + const params = { sql: BASE_SQL, dataSourceId: 42, pageNo: 2, pageSize: 50_000 }; + const confirmedResult = result({ + uuid: 'confirmed-result', + dataList: [[{ value: 'confirmed-row' }]] as any, + pageNo: 1, + pageSize: 1000, + }); + const requestSequence = 9; let transition = reduceViewTablePagingEvent( - createViewTablePagingState(requestSequence, params), + createViewTablePagingState(requestSequence, params, confirmedResult), event('rows', result({ dataList: [[{ value: 'partial' }]] as any })), requestSequence, ); @@ -223,7 +258,10 @@ test('a cancelled table browse never publishes its buffered partial rows', () => event('cancelled', result()), requestSequence, ); - assert.equal(transition.completedResult, undefined, 'cancellation leaves the visible completed result untouched'); + assert.notEqual(transition.completedResult, confirmedResult, 'rollback must publish a new object'); + assert.equal(transition.completedResult?.pageNo, 1); + assert.equal(transition.completedResult?.pageSize, 1000); + assert.deepEqual(transition.completedResult?.dataList, [[{ value: 'confirmed-row' }]]); }); test('table browse cancellation targets the active JCEF execution and releases the request', async () => { diff --git a/chat2db-community-client/src/components/ViewTable/index.tsx b/chat2db-community-client/src/components/ViewTable/index.tsx index b971ddd25a..518efc983c 100644 --- a/chat2db-community-client/src/components/ViewTable/index.tsx +++ b/chat2db-community-client/src/components/ViewTable/index.tsx @@ -51,7 +51,7 @@ const ViewTable = memo((props) => { if (executeSqlParams.dataSourceId == null || !executeSqlParams.sql) { return; } - return executePage(executeSqlParams); + return executePage(executeSqlParams, _resultData); }, [executePage], ); diff --git a/chat2db-community-client/src/hooks/useViewTablePaging.ts b/chat2db-community-client/src/hooks/useViewTablePaging.ts index 24386372f2..ba1b792df0 100644 --- a/chat2db-community-client/src/hooks/useViewTablePaging.ts +++ b/chat2db-community-client/src/hooks/useViewTablePaging.ts @@ -1,9 +1,10 @@ import { useCallback, useRef, useState } from 'react'; -import type { IExecuteSqlParams } from '@/typings'; +import type { IExecuteSqlParams, IManageResultData } from '@/typings'; import type { SqlExecutionEvent } from '@/service/sqlExecutionStream'; import useSqlExecutor from './useSqlExecutor'; import { createViewTablePagingState, + getViewTablePagingErrorMessage, normalizeViewTablePageResults, reduceViewTablePagingEvent, type ViewTablePagingState, @@ -12,13 +13,18 @@ import { export default function useViewTablePaging() { const requestSequenceRef = useRef(); const paramsRef = useRef(); + const confirmedResultRef = useRef(); const pagingStateRef = useRef(); const [resultData, setResultData] = useState(); const handleRequestStart = useCallback((requestSequence: number) => { requestSequenceRef.current = requestSequence; if (paramsRef.current) { - pagingStateRef.current = createViewTablePagingState(requestSequence, paramsRef.current); + pagingStateRef.current = createViewTablePagingState( + requestSequence, + paramsRef.current, + confirmedResultRef.current, + ); } }, []); @@ -40,10 +46,21 @@ export default function useViewTablePaging() { }); const executePage = useCallback( - (params: IExecuteSqlParams) => { + (params: IExecuteSqlParams, confirmedResult: IManageResultData) => { paramsRef.current = params; + confirmedResultRef.current = confirmedResult; return executeSQL(params).then((data) => { + const streamState = pagingStateRef.current; + if (streamState?.params === params && streamState.errorMessage) { + throw new Error(streamState.errorMessage); + } const normalizedData = normalizeViewTablePageResults(data, params); + const responseError = normalizedData + .map(getViewTablePagingErrorMessage) + .find((message): message is string => !!message); + if (responseError) { + throw new Error(responseError); + } if (normalizedData.length) { setResultData(normalizedData[0]); } diff --git a/chat2db-community-client/src/hooks/viewTablePagingModel.ts b/chat2db-community-client/src/hooks/viewTablePagingModel.ts index 9273a12056..94cf852db4 100644 --- a/chat2db-community-client/src/hooks/viewTablePagingModel.ts +++ b/chat2db-community-client/src/hooks/viewTablePagingModel.ts @@ -7,12 +7,15 @@ export type ViewTableStreamEventType = 'resultStarted' | 'rows' | 'resultFinishe export interface ViewTablePagingState { requestSequence: number; params: IExecuteSqlParams; + confirmedResult?: IManageResultData; result?: IManageResultData; + errorMessage?: string; } export interface ViewTablePagingTransition { state: ViewTablePagingState; completedResult?: IManageResultData; + errorMessage?: string; } function isResultEvent(eventType: SqlExecutionEvent['eventType']): eventType is ViewTableStreamEventType { @@ -44,8 +47,12 @@ function mergeResultEvent( }; } -export function createViewTablePagingState(requestSequence: number, params: IExecuteSqlParams) { - return { requestSequence, params } satisfies ViewTablePagingState; +export function createViewTablePagingState( + requestSequence: number, + params: IExecuteSqlParams, + confirmedResult?: IManageResultData, +) { + return { requestSequence, params, confirmedResult } satisfies ViewTablePagingState; } export function normalizeViewTablePageResults( @@ -55,12 +62,29 @@ export function normalizeViewTablePageResults( return processResultDataList(results, params); } +export function getViewTablePagingErrorMessage(result: IManageResultData) { + if (result.success !== false) { + return undefined; + } + return result.message?.trim() || result.description?.trim() || 'SQL execution failed'; +} + export function reduceViewTablePagingEvent( state: ViewTablePagingState, event: SqlExecutionEvent, requestSequence: number, ): ViewTablePagingTransition { - if (state.requestSequence !== requestSequence || !isResultEvent(event.eventType)) { + if (state.requestSequence !== requestSequence) { + return { state }; + } + if (event.eventType === 'cancelled') { + const completedResult = state.confirmedResult ? { ...state.confirmedResult } : undefined; + return { + state: { ...state, result: undefined, errorMessage: undefined }, + completedResult, + }; + } + if (!isResultEvent(event.eventType)) { return { state }; } @@ -68,6 +92,13 @@ export function reduceViewTablePagingEvent( if (!eventResult) { return { state }; } + const errorMessage = getViewTablePagingErrorMessage(eventResult); + if (errorMessage) { + return { + state: { ...state, result: undefined, errorMessage }, + errorMessage, + }; + } const result = mergeResultEvent(state.result, eventResult, event.eventType); const nextState = { ...state, result };