Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TreeNodeData | null> {
try {
return await hydrateDataSourceAfterMutation(dataSourceId, dependencies);
} catch (error) {
console.warn('Failed to refresh the datasource tree after a successful save', error);
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,37 @@ async function testFailedWriteDoesNotBlockLaterWrites() {
assert.deepEqual(events, ['failed', 'successful']);
}

async function testForcedRereadSeesAnotherWindowChanges() {
const coordinator = new HiddenTreeNodeStateCoordinator<Record<number, string[]>>();
const reads: string[][] = [];
const commits: Record<number, string[]>[] = [];

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();
Expand All @@ -211,6 +242,7 @@ async function run() {
await testResetWaitsForWritesBeforeReadingAgain();
await testInitializedStateDoesNotReadAgain();
await testFailedWriteDoesNotBlockLaterWrites();
await testForcedRereadSeesAnotherWindowChanges();
}

run().catch((error) => {
Expand Down
20 changes: 13 additions & 7 deletions chat2db-community-client/src/store/tree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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?: {
Expand Down Expand Up @@ -210,7 +210,7 @@ export const createTreeAction: StateCreator<TreeStore, [['zustand/devtools', nev
},
refreshTreeData: () => 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,
Expand Down Expand Up @@ -244,7 +244,7 @@ export const createTreeAction: StateCreator<TreeStore, [['zustand/devtools', nev
priority: refresh ? 1 : 0,
},
async (isCurrent): Promise<RootTreeLoadResult> => {
get().initHiddenTreeNodeIds();
get().initHiddenTreeNodeIds(refresh);
const result = await loadNamespaceTree(() => connectionService.getNamespaceList({ refresh }));
if (!isCurrent()) {
return { committed: false };
Expand Down Expand Up @@ -785,11 +785,17 @@ export const createTreeAction: StateCreator<TreeStore, [['zustand/devtools', nev
const curNode = findNode(nodeId, newTreeData);
return curNode?.children || [];
},
initHiddenTreeNodeIds: () => {
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) => {
Expand Down
Loading