Compare commits

...

4 Commits

Author SHA1 Message Date
Charles Bochet 57112cdecb Fix 1.17 upgrade: build from source when built-function folder is missing
v1.17.3 swallowed the error and skipped the function entirely,
meaning never-executed serverless functions were silently dropped
during migration.

Instead, when the built folder is missing, build the source
TypeScript on the fly using esbuild (same config used by
LogicFunctionResourceService) so the migration completes properly.
2026-04-13 18:49:37 +02:00
prastoin 1d84e23a1b fix(server): swallow logic function with no index.ts 2026-03-25 17:43:20 +01:00
prastoin 2e893eed0d fix 2026-03-24 14:33:33 +01:00
prastoin b782ed4ca2 fix(server): negative number check and orphan favorite 2026-03-24 10:32:28 +01:00
6 changed files with 182 additions and 26 deletions
@@ -0,0 +1,87 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@Command({
name: 'upgrade:1-18:delete-orphan-favorites',
description:
'Delete favorites whose viewId does not exist in workspace view metadata (fixes upgrade failure)',
})
export class DeleteOrphanFavoritesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(DeleteOrphanFavoritesCommand.name);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(`Deleting orphan favorites for workspace ${workspaceId}`);
const { flatViewMaps } = await this.workspaceCacheService.getOrRecompute(
workspaceId,
['flatViewMaps'],
);
const favoriteRepository =
await this.twentyORMGlobalManager.getRepository<FavoriteWorkspaceEntity>(
workspaceId,
'favorite',
{ shouldBypassPermissionChecks: true },
);
const favoritesWithViewId = await favoriteRepository.find({
where: {
deletedAt: IsNull(),
viewId: Not(IsNull()),
},
select: { id: true, viewId: true },
});
const orphanFavoriteIds = favoritesWithViewId
.filter(
(favorite) =>
!isDefined(flatViewMaps.universalIdentifierById[favorite.viewId]),
)
.map((favorite) => favorite.id);
if (orphanFavoriteIds.length === 0) {
this.logger.log(`No orphan favorites found for workspace ${workspaceId}`);
return;
}
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would delete ${orphanFavoriteIds.length} orphan favorite(s) for workspace ${workspaceId}`,
);
return;
}
await favoriteRepository.delete(orphanFavoriteIds);
this.logger.log(
`Deleted ${orphanFavoriteIds.length} orphan favorite(s) for workspace ${workspaceId}`,
);
}
}
@@ -284,7 +284,7 @@ export class MigrateFavoritesToNavigationMenuItemsCommand extends ActiveOrSuspen
folderId: null,
folderUniversalIdentifier: null,
name: favoriteFolder.name,
position: favoriteFolder.position,
position: Math.round(favoriteFolder.position),
workspaceId,
applicationId: workspaceCustomApplicationId,
applicationUniversalIdentifier:
@@ -316,7 +316,7 @@ export class MigrateFavoritesToNavigationMenuItemsCommand extends ActiveOrSuspen
folderId: null,
folderUniversalIdentifier: null,
name: favoriteFolder.name,
position: favoriteFolder.position,
position: Math.round(favoriteFolder.position),
workspaceId,
applicationId: workspaceCustomApplicationId,
applicationUniversalIdentifier:
@@ -521,7 +521,7 @@ export class MigrateFavoritesToNavigationMenuItemsCommand extends ActiveOrSuspen
folderId,
folderUniversalIdentifier: folderId,
name: null,
position: favorite.position,
position: Math.round(favorite.position),
workspaceId,
applicationId,
applicationUniversalIdentifier,
@@ -566,7 +566,7 @@ export class MigrateFavoritesToNavigationMenuItemsCommand extends ActiveOrSuspen
folderId,
folderUniversalIdentifier: folderId,
name: null,
position: favorite.position,
position: Math.round(favorite.position),
workspaceId,
applicationId: workspaceCustomApplicationId,
applicationUniversalIdentifier:
@@ -2,8 +2,13 @@ import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { build } from 'esbuild';
import { Command } from 'nest-commander';
import { NODE_ESM_CJS_BANNER } from 'twenty-shared/application';
import { FileFolder, type Sources } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
@@ -188,12 +193,18 @@ export class MigrateWorkflowCodeStepsCommand extends ActiveOrSuspendedWorkspaces
oldLogicFunction.applicationId,
);
const { builtContent, sourceContent } = await this.readOldFunctionFiles(
const oldFiles = await this.readOldFunctionFiles(
workspaceId,
serverlessFunctionId,
version,
);
if (!isDefined(oldFiles)) {
return null;
}
const { builtContent, sourceContent } = oldFiles;
const checksum = crypto
.createHash('md5')
.update(builtContent)
@@ -236,33 +247,89 @@ export class MigrateWorkflowCodeStepsCommand extends ActiveOrSuspendedWorkspaces
workspaceId: string,
serverlessFunctionId: string,
version: string,
): Promise<{ builtContent: string; sourceContent: string }> {
): Promise<{ builtContent: string; sourceContent: string } | null> {
const workspacePrefix = `workspace-${workspaceId}`;
const builtSources = await this.fileStorageService.readFolderLegacy(
`${workspacePrefix}/${OLD_BUILT_FOLDER}/${serverlessFunctionId}/${version}`,
);
let builtSources: Sources | null = null;
const sourceSources = await this.fileStorageService.readFolderLegacy(
`${workspacePrefix}/${OLD_SOURCE_FOLDER}/${serverlessFunctionId}/${version}`,
);
try {
builtSources = await this.fileStorageService.readFolderLegacy(
`${workspacePrefix}/${OLD_BUILT_FOLDER}/${serverlessFunctionId}/${version}`,
);
} catch {
this.logger.warn(
`Built folder not found for serverless function ${serverlessFunctionId}/${version} in workspace ${workspaceId}, will build from source`,
);
}
let sourceSources: Sources;
try {
sourceSources = await this.fileStorageService.readFolderLegacy(
`${workspacePrefix}/${OLD_SOURCE_FOLDER}/${serverlessFunctionId}/${version}`,
);
} catch {
this.logger.warn(
`Source folder not found for serverless function ${serverlessFunctionId}/${version} in workspace ${workspaceId}, skipping`,
);
return null;
}
// Old source layout may nest files under a `src/` key
const sourceRoot =
(sourceSources.src as Sources) ?? (sourceSources as Sources);
const builtContent = builtSources['index.mjs'] as string;
const sourceContent = sourceRoot['index.ts'] as string;
if (!isDefined(builtContent) || !isDefined(sourceContent)) {
throw new Error(
`Missing index.mjs or index.ts for serverless function ${serverlessFunctionId}/${version} in workspace ${workspaceId}`,
if (!isDefined(sourceContent)) {
this.logger.warn(
`Missing index.ts for serverless function ${serverlessFunctionId}/${version} in workspace ${workspaceId}, skipping`,
);
return null;
}
let builtContent = builtSources?.['index.mjs'] as string | undefined;
if (!isDefined(builtContent)) {
this.logger.log(
`Building serverless function ${serverlessFunctionId}/${version} from source in workspace ${workspaceId}`,
);
builtContent = await this.buildSourceToMjs(sourceContent);
}
return { builtContent, sourceContent };
}
private async buildSourceToMjs(sourceContent: string): Promise<string> {
const tempDir = await mkdtemp(join(tmpdir(), 'twenty-migrate-'));
try {
const srcDir = join(tempDir, 'src');
const outFile = join(tempDir, 'index.mjs');
await mkdir(srcDir, { recursive: true });
await writeFile(join(srcDir, 'index.ts'), sourceContent);
await build({
entryPoints: [join(srcDir, 'index.ts')],
outfile: outFile,
platform: 'node',
format: 'esm',
target: 'es2017',
bundle: true,
sourcemap: true,
packages: 'external',
banner: NODE_ESM_CJS_BANNER,
});
return await readFile(outFile, 'utf-8');
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
private async uploadFunctionFiles(
workspaceId: string,
applicationUniversalIdentifier: string,
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillApplicationPackageFilesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-backfill-application-package-files.command';
import { DeleteFileRecordsAndUpdateTableCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-delete-all-files-and-update-table.command';
import { DeleteOrphanFavoritesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-delete-orphan-favorites.command';
import { FixMorphRelationFieldNamesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-fix-morph-relation-field-names.command';
import { IdentifyWebhookMetadataCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-identify-webhook-metadata.command';
import { MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-make-webhook-universal-identifier-and-application-id-not-nullable-migration.command';
@@ -68,6 +69,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas
GlobalWorkspaceDataSourceModule,
],
providers: [
DeleteOrphanFavoritesCommand,
FixMorphRelationFieldNamesCommand,
MigrateAttachmentToMorphRelationsCommand,
MigrateFavoritesToNavigationMenuItemsCommand,
@@ -82,6 +84,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas
BackfillApplicationPackageFilesCommand,
],
exports: [
DeleteOrphanFavoritesCommand,
FixMorphRelationFieldNamesCommand,
MigrateAttachmentToMorphRelationsCommand,
MigrateFavoritesToNavigationMenuItemsCommand,
@@ -15,6 +15,7 @@ import { FixMorphRelationFieldNamesCommand } from 'src/database/commands/upgrade
import { IdentifyWebhookMetadataCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-identify-webhook-metadata.command';
import { MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-make-webhook-universal-identifier-and-application-id-not-nullable-migration.command';
import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.command';
import { DeleteOrphanFavoritesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-delete-orphan-favorites.command';
import { MigrateFavoritesToNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-favorites-to-navigation-menu-items.command';
import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-note-target-to-morph-relations.command';
import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.command';
@@ -42,6 +43,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly backfillApplicationPackageFilesCommand: BackfillApplicationPackageFilesCommand,
protected readonly deleteFileRecordsAndUpdateTableCommand: DeleteFileRecordsAndUpdateTableCommand,
protected readonly migrateAttachmentToMorphRelationsCommand: MigrateAttachmentToMorphRelationsCommand,
protected readonly deleteOrphanFavoritesCommand: DeleteOrphanFavoritesCommand,
protected readonly migrateFavoritesToNavigationMenuItemsCommand: MigrateFavoritesToNavigationMenuItemsCommand,
protected readonly migrateNoteTargetToMorphRelationsCommand: MigrateNoteTargetToMorphRelationsCommand,
protected readonly migrateTaskTargetToMorphRelationsCommand: MigrateTaskTargetToMorphRelationsCommand,
@@ -62,6 +64,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
const commands_1170: VersionCommands = [
this.migrateAttachmentToMorphRelationsCommand,
this.deleteOrphanFavoritesCommand,
this.migrateFavoritesToNavigationMenuItemsCommand,
this.migrateNoteTargetToMorphRelationsCommand,
this.migrateTaskTargetToMorphRelationsCommand,
@@ -166,13 +166,12 @@ export class FlatNavigationMenuItemValidatorService {
if (
isDefined(flatNavigationMenuItem.position) &&
(!Number.isInteger(flatNavigationMenuItem.position) ||
flatNavigationMenuItem.position < 0)
!Number.isFinite(flatNavigationMenuItem.position)
) {
validationResult.errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`Position must be a non-negative integer`,
userFriendlyMessage: msg`Position must be a non-negative integer`,
message: t`Position must be a finite number`,
userFriendlyMessage: msg`Position must be a finite number`,
});
}
@@ -293,14 +292,11 @@ export class FlatNavigationMenuItemValidatorService {
const positionUpdate = flatEntityUpdate.position;
if (
isDefined(positionUpdate) &&
(!Number.isInteger(positionUpdate) || positionUpdate < 0)
) {
if (isDefined(positionUpdate) && !Number.isFinite(positionUpdate)) {
validationResult.errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`Position must be a non-negative integer`,
userFriendlyMessage: msg`Position must be a non-negative integer`,
message: t`Position must be a finite number`,
userFriendlyMessage: msg`Position must be a finite number`,
});
}