diff --git a/app/config/errors.php b/app/config/errors.php index 0293be3212..e7b6839a20 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1246,6 +1246,26 @@ return [ 'description' => 'The specified database type is not supported for CSV import or export operations.', 'code' => 400, ], + Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED => [ + 'name' => Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED, + 'description' => 'A source projectId is required for Appwrite migrations. Provide it in the migration credentials.', + 'code' => 400, + ], + Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND => [ + 'name' => Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND, + 'description' => 'The source project for the provided projectId was not found. Verify the projectId and the API key has access to it.', + 'code' => 404, + ], + Exception::MIGRATION_SOURCE_TYPE_INVALID => [ + 'name' => Exception::MIGRATION_SOURCE_TYPE_INVALID, + 'description' => 'The migration source type is invalid. Use one of the supported source types.', + 'code' => 400, + ], + Exception::MIGRATION_DESTINATION_TYPE_INVALID => [ + 'name' => Exception::MIGRATION_DESTINATION_TYPE_INVALID, + 'description' => 'The migration destination type is invalid. Use one of the supported destination types.', + 'code' => 400, + ], /** Realtime */ Exception::REALTIME_MESSAGE_FORMAT_INVALID => [ diff --git a/composer.lock b/composer.lock index bbf0d59a96..d356362788 100644 --- a/composer.lock +++ b/composer.lock @@ -2641,16 +2641,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -2663,7 +2663,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2688,7 +2688,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -2699,12 +2699,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/http-client", @@ -2809,16 +2813,16 @@ }, { "name": "symfony/http-client-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "shasum": "" }, "require": { @@ -2831,7 +2835,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2867,7 +2871,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" }, "funding": [ { @@ -2878,12 +2882,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-29T11:18:49+00:00" + "time": "2026-03-06T13:17:50+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -3212,16 +3220,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -3239,7 +3247,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3275,7 +3283,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -3295,7 +3303,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "tbachert/spi", @@ -4531,16 +4539,16 @@ }, { "name": "utopia-php/migration", - "version": "1.10.0", + "version": "1.10.1", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "55f4863d690e775f44fec3cae4bd1f4491fed5ea" + "reference": "759d6d61b327313cbeeeb4ea0c3e2459164b4827" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/55f4863d690e775f44fec3cae4bd1f4491fed5ea", - "reference": "55f4863d690e775f44fec3cae4bd1f4491fed5ea", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/759d6d61b327313cbeeeb4ea0c3e2459164b4827", + "reference": "759d6d61b327313cbeeeb4ea0c3e2459164b4827", "shasum": "" }, "require": { @@ -4566,7 +4574,25 @@ "Utopia\\Migration\\": "src/Migration" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Utopia\\Tests\\": "tests/Migration" + } + }, + "scripts": { + "test": [ + "./vendor/bin/phpunit" + ], + "lint": [ + "./vendor/bin/pint --test" + ], + "format": [ + "./vendor/bin/pint" + ], + "check": [ + "./vendor/bin/phpstan analyse --level 3 src tests --memory-limit 2G" + ] + }, "license": [ "MIT" ], @@ -4579,10 +4605,10 @@ "utopia" ], "support": { - "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.10.0" + "source": "https://github.com/utopia-php/migration/tree/1.10.1", + "issues": "https://github.com/utopia-php/migration/issues" }, - "time": "2026-05-06T04:35:32+00:00" + "time": "2026-05-07T07:23:57+00:00" }, { "name": "utopia-php/mongo", diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 2cf8e79944..42dcacaf34 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -348,6 +348,10 @@ class Exception extends \Exception public const string MIGRATION_IN_PROGRESS = 'migration_in_progress'; public const string MIGRATION_PROVIDER_ERROR = 'migration_provider_error'; public const string MIGRATION_DATABASE_TYPE_UNSUPPORTED = 'migration_database_type_unsupported'; + public const string MIGRATION_SOURCE_PROJECT_ID_REQUIRED = 'migration_source_project_id_required'; + public const string MIGRATION_SOURCE_PROJECT_NOT_FOUND = 'migration_source_project_not_found'; + public const string MIGRATION_SOURCE_TYPE_INVALID = 'migration_source_type_invalid'; + public const string MIGRATION_DESTINATION_TYPE_INVALID = 'migration_destination_type_invalid'; /** Realtime */ public const string REALTIME_MESSAGE_FORMAT_INVALID = 'realtime_message_format_invalid'; diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 39895d38a3..b6c295b3bb 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -197,13 +197,13 @@ class Migrations extends Action $projectDB = null; $useAppwriteApiSource = false; if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) { - throw new \Exception('Source projectId is required for Appwrite migrations'); + throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED); } if (! empty($credentials['projectId'])) { $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); if ($this->sourceProject->isEmpty()) { - throw new \Exception('Source project not found for provided projectId'); + throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND); } $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); @@ -266,7 +266,7 @@ class Migrations extends Action $this->deviceForMigrations, $this->dbForProject, ), - default => throw new \Exception('Invalid source type'), + default => throw new Exception(Exception::MIGRATION_SOURCE_TYPE_INVALID), }; $resources = $migration->getAttribute('resources', []); @@ -312,7 +312,7 @@ class Migrations extends Action $options['filename'], $options['columns'] ?? [], ), - default => throw new \Exception('Invalid destination type'), + default => throw new Exception(Exception::MIGRATION_DESTINATION_TYPE_INVALID), }; } @@ -438,6 +438,7 @@ class Migrations extends Action $transfer = $source = $destination = null; $aggregatedResources = []; + $caughtError = null; $host = System::getEnv('_APP_MIGRATION_HOST'); if (empty($host)) { @@ -531,7 +532,6 @@ class Migrations extends Action if (!empty($sourceErrors) || ! empty($destinationErrors)) { $migration->setAttribute('status', 'failed'); $migration->setAttribute('stage', 'finished'); - $migration->setAttribute('errors', $this->sanitizeErrors($sourceErrors, $destinationErrors)); return; } @@ -546,35 +546,69 @@ class Migrations extends Action $migration->setAttribute('status', 'failed'); $migration->setAttribute('stage', 'finished'); - call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ - 'migrationId' => $migration->getId(), - 'source' => $migration->getAttribute('source') ?? '', - 'destination' => $migration->getAttribute('destination') ?? '', - ]); + $caughtError = $th; + // Mirror general.php's HTTP-error pattern: typed AppwriteException uses its + // registry-driven isPublishable() flag; library-thrown Migration\Exception is + // always user-facing; anything else is unknown and surfaced to Sentry. + if ($th instanceof Exception) { + $publish = $th->isPublishable(); + } elseif ($th instanceof MigrationException) { + $publish = false; + } else { + $publish = true; + } + + if ($publish) { + call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ + 'migrationId' => $migration->getId(), + 'source' => $migration->getAttribute('source') ?? '', + 'destination' => $migration->getAttribute('destination') ?? '', + ]); + } } finally { try { + $sourceErrors = $source?->getErrors() ?? []; + $destinationErrors = $destination?->getErrors() ?? []; + + if ($caughtError !== null) { + if ($caughtError instanceof MigrationException) { + // library-thrown, message constructed by us + $bubbled = $caughtError; + } elseif ($caughtError instanceof Exception) { + // typed AppwriteException — message comes from the curated registry + $bubbled = new MigrationException( + resourceName: '', + resourceGroup: '', + message: $caughtError->getMessage(), + code: $caughtError->getCode(), + previous: $caughtError, + ); + } else { + // unknown throwable — raw message may embed internal hostnames, + // DSNs, tokens, etc. Replace with a generic user-facing string; + // the original is preserved on `previous:` for Sentry. + $bubbled = new MigrationException( + resourceName: '', + resourceGroup: '', + message: 'Migration failed due to an unexpected error.', + code: $caughtError->getCode() ?: 500, + previous: $caughtError, + ); + } + $destinationErrors[] = $bubbled; + } + + $migration->setAttribute('errors', $this->sanitizeErrors( + $sourceErrors, + $destinationErrors, + )); + $this->updateMigrationDocument($migration, $project, $queueForRealtime); if ($migration->getAttribute('status', '') === 'failed') { Console::error('Migration(' . $migration->getSequence() . ':' . $migration->getId() . ') failed, Project(' . $this->project->getSequence() . ':' . $this->project->getId() . ')'); - $sourceErrors = $source?->getErrors() ?? []; - $destinationErrors = $destination?->getErrors() ?? []; - - foreach ([...$sourceErrors, ...$destinationErrors] as $error) { - /** @var MigrationException $error */ - if ($error->getCode() === 0 || $error->getCode() >= 500) { - ($this->logError)($error, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ - 'migrationId' => $migration->getId(), - 'source' => $migration->getAttribute('source') ?? '', - 'destination' => $migration->getAttribute('destination') ?? '', - 'resourceName' => $error->getResourceName(), - 'resourceGroup' => $error->getResourceGroup(), - ]); - } - } - $source?->error(); $destination?->error(); }