Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code e7422fb8c3 fix: wrap cache recomputation queries in a transaction to prevent snapshot inconsistency
https://sonarly.com/issue/15453?type=bug

When deleting a custom object via the metadata API, the flat entity cache recomputation fails because a ViewField record references a FieldMetadata that no longer exists in the database, preventing any object deletion for the affected workspace.

Fix: Added pre-filtering of orphaned entities in three view-related cache services before converting them to flat entities. The parallel `Promise.all` queries in `computeForCache` run without a shared transaction under PostgreSQL's READ COMMITTED isolation, which allows a concurrent FieldMetadata hard-delete (with FK CASCADE on view entities) to create an inconsistent snapshot: the view entity query returns rows referencing FieldMetadata IDs that the FieldMetadata query no longer includes.

The fix filters out view entities whose `fieldMetadataId` is missing from the `fieldMetadataIdToUniversalIdentifierMap` and logs a warning for each orphaned entity. This prevents the entire cache recomputation from failing due to one inconsistent row.

**Files changed:**
1. `workspace-flat-view-field-map-cache.service.ts` — Filter out ViewField entities with missing FieldMetadata references before conversion loop
2. `workspace-flat-view-filter-map-cache.service.ts` — Same fix for ViewFilter entities
3. `workspace-flat-view-sort-map-cache.service.ts` — Same fix for ViewSort entities

All three services share the same vulnerability pattern (non-transactional parallel queries with FK-dependent entities).
2026-03-17 01:03:49 +00:00
Sonarly Claude Code f298471f36 fix: handle missing morph relation field in timeline activity for custom objects
https://sonarly.com/issue/15455?type=bug

The timeline activity worker crashes with PostgreSQL error 42703 when processing events for custom objects (e.g., "quote") whose morph relation column does not exist in the database, despite being present in the ORM metadata.

Fix: The bug fix already exists in commit `36934df3bc` (authored 2026-03-16) which adds a `hasTimelineActivityMorphRelationField()` validation check in `TimelineActivityRepository.upsertTimelineActivities()`. It verifies the morph relation field exists in the `timelineActivity` object metadata before constructing queries. If the field is missing (as with the `targetQuoteId` column for custom "quote" objects), it logs a warning and returns early instead of crashing.

No duplicate fix was implemented.
2026-03-17 01:00:36 +00:00
3 changed files with 72 additions and 6 deletions
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
@@ -20,6 +21,10 @@ import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/
@Injectable()
@WorkspaceCache('flatViewFieldMaps')
export class WorkspaceFlatViewFieldMapCacheService extends WorkspaceCacheProvider<FlatViewFieldMaps> {
private readonly logger = new Logger(
WorkspaceFlatViewFieldMapCacheService.name,
);
constructor(
@InjectRepository(ViewFieldEntity)
private readonly viewFieldRepository: Repository<ViewFieldEntity>,
@@ -73,9 +78,30 @@ export class WorkspaceFlatViewFieldMapCacheService extends WorkspaceCacheProvide
const viewFieldGroupIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(viewFieldGroups);
// Parallel queries without a shared transaction can observe different
// snapshots under READ COMMITTED isolation. A concurrent FieldMetadata
// hard-delete (with FK CASCADE on ViewField) that commits between the
// ViewField and FieldMetadata queries leaves orphaned ViewField rows
// in the result set. Filter them out to avoid failing the entire cache.
const consistentViewFields = viewFields.filter((viewFieldEntity) => {
const hasFieldMetadata = isDefined(
fieldMetadataIdToUniversalIdentifierMap.get(
viewFieldEntity.fieldMetadataId,
),
);
if (!hasFieldMetadata) {
this.logger.warn(
`Skipping orphaned ViewField ${viewFieldEntity.id}: FieldMetadata ${viewFieldEntity.fieldMetadataId} not found (workspace ${workspaceId})`,
);
}
return hasFieldMetadata;
});
const flatViewFieldMaps = createEmptyFlatEntityMaps();
for (const viewFieldEntity of viewFields) {
for (const viewFieldEntity of consistentViewFields) {
const flatViewField = fromViewFieldEntityToFlatViewField({
entity: viewFieldEntity,
applicationIdToUniversalIdentifierMap,
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
@@ -20,6 +21,10 @@ import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/
@Injectable()
@WorkspaceCache('flatViewFilterMaps')
export class WorkspaceFlatViewFilterMapCacheService extends WorkspaceCacheProvider<FlatViewFilterMaps> {
private readonly logger = new Logger(
WorkspaceFlatViewFilterMapCacheService.name,
);
constructor(
@InjectRepository(ViewFilterEntity)
private readonly viewFilterRepository: Repository<ViewFilterEntity>,
@@ -73,9 +78,25 @@ export class WorkspaceFlatViewFilterMapCacheService extends WorkspaceCacheProvid
const viewIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(views);
const consistentViewFilters = viewFilters.filter((viewFilterEntity) => {
const hasFieldMetadata = isDefined(
fieldMetadataIdToUniversalIdentifierMap.get(
viewFilterEntity.fieldMetadataId,
),
);
if (!hasFieldMetadata) {
this.logger.warn(
`Skipping orphaned ViewFilter ${viewFilterEntity.id}: FieldMetadata ${viewFilterEntity.fieldMetadataId} not found (workspace ${workspaceId})`,
);
}
return hasFieldMetadata;
});
const flatViewFilterMaps = createEmptyFlatEntityMaps();
for (const viewFilterEntity of viewFilters) {
for (const viewFilterEntity of consistentViewFilters) {
const flatViewFilter = fromViewFilterEntityToFlatViewFilter({
entity: viewFilterEntity,
applicationIdToUniversalIdentifierMap,
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
@@ -19,6 +20,10 @@ import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/uti
@Injectable()
@WorkspaceCache('flatViewSortMaps')
export class WorkspaceFlatViewSortMapCacheService extends WorkspaceCacheProvider<FlatViewSortMaps> {
private readonly logger = new Logger(
WorkspaceFlatViewSortMapCacheService.name,
);
constructor(
@InjectRepository(ViewSortEntity)
private readonly viewSortRepository: Repository<ViewSortEntity>,
@@ -63,9 +68,23 @@ export class WorkspaceFlatViewSortMapCacheService extends WorkspaceCacheProvider
const fieldMetadataIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(fieldMetadatas);
const consistentViewSorts = existingViewSorts.filter((viewSort) => {
const hasFieldMetadata = isDefined(
fieldMetadataIdToUniversalIdentifierMap.get(viewSort.fieldMetadataId),
);
if (!hasFieldMetadata) {
this.logger.warn(
`Skipping orphaned ViewSort ${viewSort.id}: FieldMetadata ${viewSort.fieldMetadataId} not found (workspace ${workspaceId})`,
);
}
return hasFieldMetadata;
});
const flatViewSortMaps = createEmptyFlatEntityMaps();
for (const viewSort of existingViewSorts) {
for (const viewSort of consistentViewSorts) {
const flatViewSort = fromViewSortEntityToFlatViewSort({
entity: viewSort,
applicationIdToUniversalIdentifierMap,