Fix syncApplication failing when navigation menu item child is listed before folder in manifest (#19599)

## Summary

- Fix `syncApplication` crashing with `ENTITY_NOT_FOUND` when a
navigation menu item child (with `folderUniversalIdentifier`) appears
before its folder in the manifest's `navigationMenuItems` array
- Add a generic topological sort in
`WorkspaceEntityMigrationBuilderService.validateAndBuild()` that detects
self-referential FKs from `ALL_MANY_TO_ONE_METADATA_RELATIONS` and
ensures parents are created before children
- This also covers other self-referential entities:
`viewFilterGroup.parentViewFilterGroup`,
`fieldMetadata.relationTargetFieldMetadata`, and
`rowLevelPermissionPredicateGroup.parentRowLevelPermissionPredicateGroup`

## Root cause

The builder iterated `createdFlatEntityMaps.byUniversalIdentifier` in
insertion order (from the manifest). Validation passed because it
checked both optimistic maps and remaining-to-create maps. But the
runner processed create actions sequentially, so
`resolveUniversalRelationIdentifiersToIds` threw when the folder hadn't
been created yet.
This commit is contained in:
Charles Bochet
2026-04-12 12:35:38 +02:00
committed by GitHub
parent c8f5ecb2b6
commit 3ae63f0574
4 changed files with 607 additions and 1 deletions
@@ -0,0 +1,208 @@
import { v4 as uuidv4 } from 'uuid';
import { type UniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-maps.type';
import { topologicallySortUniversalFlatEntitiesForSelfReferentialFks } from 'src/engine/workspace-manager/workspace-migration/utils/topologically-sort-universal-flat-entities-for-self-referential-fks.util';
const APPLICATION_UNIVERSAL_IDENTIFIER = uuidv4();
type TestNavigationMenuItemEntity = {
universalIdentifier: string;
applicationUniversalIdentifier: string;
folderUniversalIdentifier: string | null;
};
const createEntity = (
universalIdentifier: string,
folderUniversalIdentifier: string | null = null,
): TestNavigationMenuItemEntity => ({
universalIdentifier,
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier,
});
const buildMaps = (
entities: TestNavigationMenuItemEntity[],
): UniversalFlatEntityMaps<TestNavigationMenuItemEntity> => ({
byUniversalIdentifier: Object.fromEntries(
entities.map((entity) => [entity.universalIdentifier, entity]),
),
});
describe('topologicallySortUniversalFlatEntitiesForSelfReferentialFks', () => {
it('returns empty array for empty maps', () => {
const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({
metadataName: 'navigationMenuItem',
universalFlatEntityMaps: buildMaps([]) as never,
});
expect(result).toEqual([]);
});
it('returns original order for entities without self-referential FKs', () => {
const idA = uuidv4();
const idB = uuidv4();
const maps = {
byUniversalIdentifier: {
[idA]: {
universalIdentifier: idA,
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
},
[idB]: {
universalIdentifier: idB,
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
},
},
};
const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({
metadataName: 'objectMetadata',
universalFlatEntityMaps: maps as never,
});
expect(result).toEqual([idA, idB]);
});
it('returns original order when no entity references another in the batch', () => {
const itemA = uuidv4();
const itemB = uuidv4();
const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({
metadataName: 'navigationMenuItem',
universalFlatEntityMaps: buildMaps([
createEntity(itemA),
createEntity(itemB),
]) as never,
});
expect(result).toEqual([itemA, itemB]);
});
it('sorts parent before child when child is listed first', () => {
const parentId = uuidv4();
const childId = uuidv4();
const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({
metadataName: 'navigationMenuItem',
universalFlatEntityMaps: buildMaps([
createEntity(childId, parentId),
createEntity(parentId),
]) as never,
});
expect(result).toEqual([parentId, childId]);
});
it('keeps parent before child when already in correct order', () => {
const parentId = uuidv4();
const childId = uuidv4();
const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({
metadataName: 'navigationMenuItem',
universalFlatEntityMaps: buildMaps([
createEntity(parentId),
createEntity(childId, parentId),
]) as never,
});
expect(result).toEqual([parentId, childId]);
});
it('sorts multi-level hierarchy: grandparent -> parent -> child', () => {
const grandparentId = uuidv4();
const parentId = uuidv4();
const childId = uuidv4();
const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({
metadataName: 'navigationMenuItem',
universalFlatEntityMaps: buildMaps([
createEntity(childId, parentId),
createEntity(grandparentId),
createEntity(parentId, grandparentId),
]) as never,
});
expect(result).toEqual([grandparentId, parentId, childId]);
});
it('handles multiple roots with children', () => {
const rootA = uuidv4();
const rootB = uuidv4();
const childA = uuidv4();
const childB = uuidv4();
const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({
metadataName: 'navigationMenuItem',
universalFlatEntityMaps: buildMaps([
createEntity(childB, rootB),
createEntity(childA, rootA),
createEntity(rootA),
createEntity(rootB),
]) as never,
});
expect(result.indexOf(rootA)).toBeLessThan(result.indexOf(childA));
expect(result.indexOf(rootB)).toBeLessThan(result.indexOf(childB));
expect(result).toHaveLength(4);
});
it('ignores references to entities outside the batch', () => {
const externalParentId = uuidv4();
const itemA = uuidv4();
const itemB = uuidv4();
const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({
metadataName: 'navigationMenuItem',
universalFlatEntityMaps: buildMaps([
createEntity(itemA, externalParentId),
createEntity(itemB),
]) as never,
});
expect(result).toEqual([itemA, itemB]);
});
it('throws on cycles for entities without expected cycles', () => {
const idA = uuidv4();
const idB = uuidv4();
expect(() =>
topologicallySortUniversalFlatEntitiesForSelfReferentialFks({
metadataName: 'navigationMenuItem',
universalFlatEntityMaps: buildMaps([
createEntity(idA, idB),
createEntity(idB, idA),
]) as never,
}),
).toThrow(/Cyclic self-referential foreign key detected/);
});
it('appends cyclic entities for fieldMetadata (expected bidirectional cycles)', () => {
const idA = uuidv4();
const idB = uuidv4();
const maps = {
byUniversalIdentifier: {
[idA]: {
universalIdentifier: idA,
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: idB,
},
[idB]: {
universalIdentifier: idB,
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: idA,
},
},
};
const result = topologicallySortUniversalFlatEntitiesForSelfReferentialFks({
metadataName: 'fieldMetadata',
universalFlatEntityMaps: maps as never,
});
expect(result).toHaveLength(2);
expect(result).toContain(idA);
expect(result).toContain(idB);
});
});
@@ -0,0 +1,139 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ALL_MANY_TO_ONE_METADATA_RELATIONS } from 'src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant';
import { type MetadataUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-maps.type';
// fieldMetadata has bidirectional self-references (relation field A -> B and
// B -> A) that are handled by a separate pre-allocation code path in the
// runner. Cycles are expected and safe to append unsorted here.
const METADATA_NAMES_WITH_EXPECTED_CYCLES = new Set<AllMetadataName>([
'fieldMetadata',
]);
const getSelfReferentialUniversalForeignKeys = (
metadataName: AllMetadataName,
): string[] =>
Object.values(ALL_MANY_TO_ONE_METADATA_RELATIONS[metadataName])
.filter(
(
relation,
): relation is {
metadataName: AllMetadataName;
universalForeignKey: string;
} => isDefined(relation) && relation.metadataName === metadataName,
)
.map((relation) => relation.universalForeignKey);
const getParentUniversalIdentifier = ({
entity,
selfReferentialUniversalForeignKeys,
}: {
entity: Record<string, unknown>;
selfReferentialUniversalForeignKeys: string[];
}): string | null =>
selfReferentialUniversalForeignKeys.reduce<string | null>(
(found, universalForeignKey) =>
found ??
(entity[universalForeignKey] as string | null | undefined) ??
null,
null,
);
// Sorts universal identifiers so that parents come before children
// for entities with self-referential FKs (e.g. navigationMenuItem.folder,
// viewFilterGroup.parentViewFilterGroup). Returns original order unchanged
// for entities without self-referential FKs.
export const topologicallySortUniversalFlatEntitiesForSelfReferentialFks = <
T extends AllMetadataName,
>({
metadataName,
universalFlatEntityMaps,
}: {
metadataName: T;
universalFlatEntityMaps: MetadataUniversalFlatEntityMaps<T>;
}): string[] => {
const selfReferentialUniversalForeignKeys =
getSelfReferentialUniversalForeignKeys(metadataName);
const allUniversalIdentifiers = Object.keys(
universalFlatEntityMaps.byUniversalIdentifier,
);
if (selfReferentialUniversalForeignKeys.length === 0) {
return allUniversalIdentifiers;
}
const universalIdentifierSet = new Set(allUniversalIdentifiers);
const childrenByParent = new Map<string, string[]>();
const inDegree = new Map<string, number>(
allUniversalIdentifiers.map((id) => [id, 0]),
);
for (const universalIdentifier of allUniversalIdentifiers) {
const entity = universalFlatEntityMaps.byUniversalIdentifier[
universalIdentifier
] as Record<string, unknown> | undefined;
if (!isDefined(entity)) {
continue;
}
const parentId = getParentUniversalIdentifier({
entity,
selfReferentialUniversalForeignKeys,
});
if (isDefined(parentId) && universalIdentifierSet.has(parentId)) {
childrenByParent.set(parentId, [
...(childrenByParent.get(parentId) ?? []),
universalIdentifier,
]);
inDegree.set(
universalIdentifier,
(inDegree.get(universalIdentifier) ?? 0) + 1,
);
}
}
const roots = allUniversalIdentifiers.filter(
(id) => (inDegree.get(id) ?? 0) === 0,
);
const sorted = roots.reduce<string[]>((accumulator, root) => {
accumulator.push(root);
for (let i = accumulator.length - 1; i < accumulator.length; i++) {
const children = childrenByParent.get(accumulator[i]) ?? [];
accumulator.push(...children);
}
return accumulator;
}, []);
if (sorted.length < allUniversalIdentifiers.length) {
if (!METADATA_NAMES_WITH_EXPECTED_CYCLES.has(metadataName)) {
throw new Error(
`Cyclic self-referential foreign key detected for ${metadataName}: ` +
`expected ${allUniversalIdentifiers.length} entities but sorted ${sorted.length}. ` +
`This entity does not use deferrable FKs, so cycles are not supported.`,
);
}
// Bidirectional self-references (e.g. fieldMetadata relation pairs where
// A -> B and B -> A) create cycles that cannot be topologically sorted.
// These rely on DEFERRABLE INITIALLY DEFERRED FKs at the DB level,
// so we append them at the end in their original order.
const sortedSet = new Set(sorted);
for (const universalIdentifier of allUniversalIdentifiers) {
if (!sortedSet.has(universalIdentifier)) {
sorted.push(universalIdentifier);
}
}
}
return sorted;
};
@@ -27,6 +27,7 @@ import { resetUniversalFlatEntityForeignKeyAggregators } from 'src/engine/worksp
import { flatEntityDeletedCreatedUpdatedMatrixDispatcher } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/universal-flat-entity-deleted-created-updated-matrix-dispatcher.util';
import { getMetadataEmptyWorkspaceMigrationActionRecord } from 'src/engine/workspace-manager/workspace-migration/utils/get-metadata-empty-workspace-migration-action-record.util';
import { shouldInferDeletionFromMissingEntities } from 'src/engine/workspace-manager/workspace-migration/utils/should-infer-deletion-from-missing-entities.util';
import { topologicallySortUniversalFlatEntitiesForSelfReferentialFks } from 'src/engine/workspace-manager/workspace-migration/utils/topologically-sort-universal-flat-entities-for-self-referential-fks.util';
import { FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
import { FailedFlatEntityValidateAndBuild } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/failed-flat-entity-validate-and-build.type';
import { SuccessfulFlatEntityValidateAndBuild } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/successful-flat-entity-validate-and-build.type';
@@ -247,7 +248,14 @@ export abstract class WorkspaceEntityMigrationBuilderService<
`EntityBuilder ${this.metadataName}`,
'creation validation',
);
for (const flatEntityToCreateUniversalIdentifier in createdFlatEntityMaps.byUniversalIdentifier) {
const sortedCreateUniversalIdentifiers =
topologicallySortUniversalFlatEntitiesForSelfReferentialFks({
metadataName: this.metadataName,
universalFlatEntityMaps: createdFlatEntityMaps,
});
for (const flatEntityToCreateUniversalIdentifier of sortedCreateUniversalIdentifiers) {
const rawUniversalflatEntityToCreate =
findFlatEntityByUniversalIdentifierOrThrow({
universalIdentifier: flatEntityToCreateUniversalIdentifier,
@@ -0,0 +1,251 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { findNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items.util';
import { type Manifest } from 'twenty-shared/application';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_FOLDER_ID = uuidv4();
const TEST_CHILD_ID = uuidv4();
const NAV_ITEM_GQL_FIELDS = `
id
type
name
icon
link
position
folderId
applicationId
`;
let testApplicationId: string;
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'navigationMenuItems'>>,
) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides,
});
const findAppNavigationMenuItems = async () => {
const { data } = await findNavigationMenuItems({
gqlFields: NAV_ITEM_GQL_FIELDS,
expectToFail: false,
input: undefined,
});
return data.navigationMenuItems.filter(
(item) => item.applicationId === testApplicationId,
);
};
describe('Manifest update - navigation menu items', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing navigation menu item manifest updates',
sourcePath: 'test-manifest-update-nav',
});
const result = await globalThis.testDataSource.query(
`SELECT id FROM core."application" WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
testApplicationId = result[0].id;
}, 60000);
afterEach(async () => {
try {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
} catch {
// May fail if the test didn't fully install/sync
}
await globalThis.testDataSource.query(
`DELETE FROM core."role" WHERE "universalIdentifier" = $1`,
[TEST_ROLE_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."file" WHERE "applicationId" IN (
SELECT id FROM core."application" WHERE "universalIdentifier" = $1
)`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."application"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
});
it('should create a folder and a child item when child is listed BEFORE folder in manifest', async () => {
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_CHILD_ID,
type: NavigationMenuItemType.LINK,
name: 'Child Link',
icon: 'IconLink',
position: 1,
link: 'https://example.com',
folderUniversalIdentifier: TEST_FOLDER_ID,
},
{
universalIdentifier: TEST_FOLDER_ID,
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
icon: 'IconFolder',
position: 0,
},
],
}),
expectToFail: false,
});
const items = await findAppNavigationMenuItems();
expect(items).toHaveLength(2);
const folder = items.find(
(item) => item.type === NavigationMenuItemType.FOLDER,
);
const child = items.find(
(item) => item.type === NavigationMenuItemType.LINK,
);
expect(folder).toBeDefined();
expect(folder).toMatchObject({
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
});
expect(child).toBeDefined();
expect(child).toMatchObject({
type: NavigationMenuItemType.LINK,
name: 'Child Link',
folderId: folder!.id,
});
}, 60000);
it('should update navigation menu item properties on second sync', async () => {
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_FOLDER_ID,
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
icon: 'IconFolder',
position: 0,
},
],
}),
expectToFail: false,
});
const itemsAfterFirstSync = await findAppNavigationMenuItems();
expect(itemsAfterFirstSync).toHaveLength(1);
expect(itemsAfterFirstSync[0]).toMatchObject({
name: 'Test Folder',
icon: 'IconFolder',
});
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_FOLDER_ID,
type: NavigationMenuItemType.FOLDER,
name: 'Renamed Folder',
icon: 'IconFolderOpen',
position: 0,
},
],
}),
expectToFail: false,
});
const itemsAfterSecondSync = await findAppNavigationMenuItems();
expect(itemsAfterSecondSync).toHaveLength(1);
expect(itemsAfterSecondSync[0]).toMatchObject({
name: 'Renamed Folder',
icon: 'IconFolderOpen',
});
}, 60000);
it('should delete navigation menu items when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_FOLDER_ID,
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
icon: 'IconFolder',
position: 0,
},
{
universalIdentifier: TEST_CHILD_ID,
type: NavigationMenuItemType.LINK,
name: 'Child Link',
icon: 'IconLink',
position: 1,
link: 'https://example.com',
folderUniversalIdentifier: TEST_FOLDER_ID,
},
],
}),
expectToFail: false,
});
const itemsAfterFirstSync = await findAppNavigationMenuItems();
expect(itemsAfterFirstSync).toHaveLength(2);
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_FOLDER_ID,
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
icon: 'IconFolder',
position: 0,
},
],
}),
expectToFail: false,
});
const itemsAfterSecondSync = await findAppNavigationMenuItems();
expect(itemsAfterSecondSync).toHaveLength(1);
expect(itemsAfterSecondSync[0]).toMatchObject({
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
});
}, 60000);
});