diff --git a/chat2db-community-client/src/store/tree/dataSourceMutationRefresh.test.ts b/chat2db-community-client/src/store/tree/dataSourceMutationRefresh.test.ts index 1eee769028..d75f5fffb7 100644 --- a/chat2db-community-client/src/store/tree/dataSourceMutationRefresh.test.ts +++ b/chat2db-community-client/src/store/tree/dataSourceMutationRefresh.test.ts @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; -import { hydrateDataSourceAfterMutation } from './dataSourceMutationRefresh'; +import { + hydrateDataSourceAfterMutation, + runMutationRefreshQuietly, +} from './dataSourceMutationRefresh'; async function testUsesCanonicalNodeLoadedAfterMutation() { const events: string[] = []; @@ -110,11 +113,62 @@ async function testPropagatesRefreshFailure() { assert.deepEqual(events, ['refresh']); } +async function testQuietWrapperSwallowsRefreshFailureAfterSuccessfulSave() { + const events: string[] = []; + const originalWarn = console.warn; + const warnings: unknown[] = []; + console.warn = (...args: unknown[]) => { + warnings.push(args[0]); + }; + try { + const result = await runMutationRefreshQuietly(42, { + refreshTreeData: async () => { + events.push('refresh'); + throw new Error('refresh failed'); + }, + getDataSourceList: () => { + events.push('read'); + return []; + }, + setSelectedKeys: () => events.push('select'), + setScrollTargetKey: () => events.push('scroll'), + loadData: async () => { + events.push('load'); + }, + }); + + assert.equal(result, null, 'a failed post-save refresh must resolve, not reject'); + assert.deepEqual(events, ['refresh']); + assert.equal(warnings.length, 1, 'the refresh failure is logged once'); + } finally { + console.warn = originalWarn; + } +} + +async function testQuietWrapperStillHydratesOnSuccess() { + const canonicalNode = { + key: 'dataSource_42', + extraParams: { dataSourceId: 42 }, + } as any; + + const result = await runMutationRefreshQuietly(42, { + refreshTreeData: async () => true, + getDataSourceList: () => [canonicalNode], + setSelectedKeys: () => undefined, + setScrollTargetKey: () => undefined, + loadData: async () => undefined, + }); + + assert.equal(result, canonicalNode); +} + Promise.all([ testUsesCanonicalNodeLoadedAfterMutation(), testDoesNotReuseSparseMutationNodeWhenRefreshMisses(), testStopsWhenRefreshIsNotCommitted(), testPropagatesRefreshFailure(), + testQuietWrapperSwallowsRefreshFailureAfterSuccessfulSave(), + testQuietWrapperStillHydratesOnSuccess(), ]) .then(() => { console.log('Data source mutation refresh tests passed'); diff --git a/chat2db-community-client/src/store/tree/dataSourceMutationRefresh.ts b/chat2db-community-client/src/store/tree/dataSourceMutationRefresh.ts index 654f902381..6e6d51ab2e 100644 --- a/chat2db-community-client/src/store/tree/dataSourceMutationRefresh.ts +++ b/chat2db-community-client/src/store/tree/dataSourceMutationRefresh.ts @@ -31,3 +31,21 @@ export async function hydrateDataSourceAfterMutation( await dependencies.loadData(dataSource); return dataSource; } + +/** + * Runs the post-mutation refresh. The mutation itself already succeeded, so + * a failed refresh is logged instead of rejecting into the caller's + * save-failure handling (which would misreport the save as failed); the + * tree simply stays stale until the next refresh. + */ +export async function runMutationRefreshQuietly( + dataSourceId: number, + dependencies: DataSourceMutationRefreshDependencies, +): Promise { + try { + return await hydrateDataSourceAfterMutation(dataSourceId, dependencies); + } catch (error) { + console.warn('Failed to refresh the datasource tree after a successful save', error); + return null; + } +} diff --git a/chat2db-community-client/src/store/tree/hiddenTreeNodeState.test.ts b/chat2db-community-client/src/store/tree/hiddenTreeNodeState.test.ts index dbdaf53e61..9b50915c34 100644 --- a/chat2db-community-client/src/store/tree/hiddenTreeNodeState.test.ts +++ b/chat2db-community-client/src/store/tree/hiddenTreeNodeState.test.ts @@ -203,6 +203,37 @@ async function testFailedWriteDoesNotBlockLaterWrites() { assert.deepEqual(events, ['failed', 'successful']); } +async function testForcedRereadSeesAnotherWindowChanges() { + const coordinator = new HiddenTreeNodeStateCoordinator>(); + const reads: string[][] = []; + const commits: Record[] = []; + + const firstInitialization = coordinator.initialize( + async () => { + reads.push(['first']); + return { 1: ['first'] }; + }, + (value) => commits.push(value), + ); + assert.equal(await firstInitialization, true); + + // Another window persisted different hidden nodes; a forced refresh + // (reset + initialize, what initHiddenTreeNodeIds(true) performs) must + // re-read instead of keeping this window's lifetime cache. + coordinator.reset(); + const secondInitialization = coordinator.initialize( + async () => { + reads.push(['second']); + return { 1: ['second'] }; + }, + (value) => commits.push(value), + ); + + assert.equal(await secondInitialization, true); + assert.deepEqual(reads, [['first'], ['second']]); + assert.deepEqual(commits, [{ 1: ['first'] }, { 1: ['second'] }]); +} + async function run() { await testWriteWaitsForPendingInitialization(); await testConcurrentChangesPreserveInitializedData(); @@ -211,6 +242,7 @@ async function run() { await testResetWaitsForWritesBeforeReadingAgain(); await testInitializedStateDoesNotReadAgain(); await testFailedWriteDoesNotBlockLaterWrites(); + await testForcedRereadSeesAnotherWindowChanges(); } run().catch((error) => { diff --git a/chat2db-community-client/src/store/tree/index.tsx b/chat2db-community-client/src/store/tree/index.tsx index da6accb1d4..2a2bdc8854 100644 --- a/chat2db-community-client/src/store/tree/index.tsx +++ b/chat2db-community-client/src/store/tree/index.tsx @@ -26,7 +26,7 @@ import { import { DataSourceIdentityColorPatch, patchDataSourceIdentityTree } from './dataSourceIdentity'; import { collectDataSourceNodes, pruneDataSourceRuntimeAvailability } from './dataSourceList'; import { shouldReuseTreeNodeChildren } from './treeNodeLoadState'; -import { hydrateDataSourceAfterMutation } from './dataSourceMutationRefresh'; +import { runMutationRefreshQuietly } from './dataSourceMutationRefresh'; import { applyHiddenTreeNodeChanges, HiddenTreeNodeStateCoordinator } from './hiddenTreeNodeState'; import { LatestLoadCoordinator, loadNamespaceTree } from './loadNamespaceTree'; import { @@ -158,7 +158,7 @@ export interface TreeAction { updateOriginalTitleByNodeId: (nodeKey: string, originalTitle: string) => void; // Get the child nodes under a certain node. If the child node is undefined, request the child node. getChildrenByNodeId: (nodeId: string) => TreeNodeData[]; - initHiddenTreeNodeIds: () => void; + initHiddenTreeNodeIds: (force?: boolean) => void; addOrDeleteShowTreeNodeIds: ( dataSourceId: number, changedKeys?: { @@ -210,7 +210,7 @@ export const createTreeAction: StateCreator get().getTreeData({ refresh: true }), refreshDataSourceAfterMutation: async (dataSourceId) => { - await hydrateDataSourceAfterMutation(dataSourceId, { + await runMutationRefreshQuietly(dataSourceId, { refreshTreeData: () => get().getTreeData({ refresh: true, throwOnError: true }), getDataSourceList: () => get().dataSourceList, setSelectedKeys: get().setSelectedKeys, @@ -244,7 +244,7 @@ export const createTreeAction: StateCreator => { - get().initHiddenTreeNodeIds(); + get().initHiddenTreeNodeIds(refresh); const result = await loadNamespaceTree(() => connectionService.getNamespaceList({ refresh })); if (!isCurrent()) { return { committed: false }; @@ -785,11 +785,17 @@ export const createTreeAction: StateCreator { + initHiddenTreeNodeIds: (force = false) => { + if (force) { + // Another window may have changed the persisted hidden-node config; + // a manual refresh must re-read it instead of keeping this window's + // lifetime cache. + hiddenTreeNodeStateCoordinator.reset(); + set({ hiddenTreeNodeIds: null }); + } if (get().hiddenTreeNodeIds !== null) { return; - } - void hiddenTreeNodeStateCoordinator + } void hiddenTreeNodeStateCoordinator .initialize( () => dataSourceTreeService.getTreeHiddenTreeNodeIds(), (hiddenTreeNodeIds) => {