Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code f8b5f6b52f chore: improve monitoring for fix(view): prevent duplicate view creation on dash
## Monitoring: Handle WorkspaceMigrationRunnerException in ViewGraphqlApiExceptionFilter

The `ViewGraphqlApiExceptionFilter` catches `WorkspaceMigrationBuilderException` but not `WorkspaceMigrationRunnerException`. When the migration runner fails (e.g., due to a duplicate key violation during view creation), the exception propagates unhandled and is captured by Sentry as a raw error — adding noise to monitoring.

**Change:** Added `WorkspaceMigrationRunnerException` to the `@Catch()` decorator and the union type in the `catch()` method. The handler delegates to the existing `workspaceMigrationRunnerExceptionFormatter` (already used in `WorkspaceMigrationGraphqlApiExceptionInterceptor` for other resolvers) which converts it to a structured `BaseGraphQLError` with proper error codes and user-friendly messages. This ensures:

1. The error returns as a structured GraphQL error to the client instead of an unhandled exception
2. Sentry captures it as a handled error (reducing noise)
3. The frontend's existing `handleMetadataError` can properly process the structured error response
2026-05-04 00:48:12 +00:00
Sonarly Claude Code 2791fbd1bb fix(view): prevent duplicate view creation on dashboard save retry
https://sonarly.com/issue/33790?type=bug

When saving a dashboard layout with a record table widget, the view is created server-side but the client-side persisted state is only updated after the entire save succeeds. If the page layout update fails or the user saves again, the same view ID is re-sent, causing a duplicate primary key violation on the `core.view` table.

Fix: ## Fix: Prevent duplicate view creation on dashboard save retry

**Root cause:** `useCreatePendingRecordTableWidgetViews` checks `pageLayoutPersistedComponentState` to determine if a view already exists, but this state is only updated after the entire save flow completes. If view creation succeeds but the subsequent page layout update fails, the persisted state is never updated, and a retry attempts to create the same view again — hitting a primary key constraint violation.

**Change:** Added a `useRef<Set<string>>` (`createdViewIdsRef`) that tracks view IDs successfully created via the API. Before attempting to create a view, the code now also checks this ref in addition to the persisted page layout state. This ensures that even if the persisted state hasn't been updated (due to a partial save failure), the function knows not to re-create a view it already created.

The ref is the right primitive here:
- It persists across re-renders and `useCallback` invocations without causing re-renders
- It doesn't need to be in the dependency array (stable reference)
- It naturally scopes to the component lifecycle, so it resets when the component unmounts
2026-05-04 00:48:11 +00:00
2 changed files with 17 additions and 3 deletions
@@ -4,7 +4,7 @@ import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/r
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { WidgetType } from '~/generated-metadata/graphql';
@@ -12,6 +12,7 @@ export const useCreatePendingRecordTableWidgetViews = () => {
const { performViewAPICreate, performViewAPIDestroy } =
usePerformViewAPIPersist();
const store = useStore();
const createdViewIdsRef = useRef<Set<string>>(new Set());
const createPendingRecordTableWidgetViews = useCallback(
async (pageLayoutId: string) => {
@@ -59,7 +60,10 @@ export const useCreatePendingRecordTableWidgetViews = () => {
const persistedViewId = persistedRecordTableWidgets.get(widget.id);
if (persistedViewId === viewId) {
if (
persistedViewId === viewId ||
createdViewIdsRef.current.has(viewId)
) {
continue;
}
@@ -98,6 +102,8 @@ export const useCreatePendingRecordTableWidgetViews = () => {
`Failed to create view for RECORD_TABLE widget ${widget.id}`,
);
}
createdViewIdsRef.current.add(view.id);
}
for (const [widgetId, viewId] of persistedRecordTableWidgets) {
@@ -18,6 +18,8 @@ import { ViewSortException } from 'src/engine/metadata-modules/view-sort/excepti
import { ViewException } from 'src/engine/metadata-modules/view/exceptions/view.exception';
import { viewGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception-handler.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { workspaceMigrationRunnerExceptionFormatter } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-runner-exception-formatter';
import { WorkspaceMigrationRunnerException } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
@Catch(
ViewException,
@@ -27,6 +29,7 @@ import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager
ViewGroupException,
ViewSortException,
WorkspaceMigrationBuilderException,
WorkspaceMigrationRunnerException,
)
@Injectable()
export class ViewGraphqlApiExceptionFilter implements ExceptionFilter {
@@ -41,7 +44,8 @@ export class ViewGraphqlApiExceptionFilter implements ExceptionFilter {
| ViewFilterGroupException
| ViewGroupException
| ViewSortException
| WorkspaceMigrationBuilderException,
| WorkspaceMigrationBuilderException
| WorkspaceMigrationRunnerException,
host: ExecutionContext,
) {
const gqlContext = GqlExecutionContext.create(host);
@@ -49,6 +53,10 @@ export class ViewGraphqlApiExceptionFilter implements ExceptionFilter {
const userLocale = ctx.req?.locale ?? SOURCE_LOCALE;
const i18n = this.i18nService.getI18nInstance(userLocale);
if (exception instanceof WorkspaceMigrationRunnerException) {
return workspaceMigrationRunnerExceptionFormatter(exception);
}
return viewGraphqlApiExceptionHandler(exception, i18n);
}
}