From 9c35ba5e53671be4ff7897dbf1d589ff7acdd0d2 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 10 Sep 2026 14:13:26 +0530 Subject: [PATCH 1/5] feat: report resources requested without their prerequisites A resource whose prerequisites are absent from the same request cannot be transferred. Its exporter walks a cache the missing prerequisite never filled, or is only reached by the prerequisite's own exporter, and neither path raises anything: the transfer finishes reporting success having moved none of it. Requesting membership without team is the case that reaches users, and it leaves the membership counters pending while the migration reports completed. Let a source declare what each resource needs alongside it and record an error naming whatever is absent, so the transfer fails where it used to go quiet. Sources declare nothing by default. The Appwrite source declares the pairings its exporters actually rely on. Index, collection, attribute and document are left out: they are shared between the tables, documents and vectors flavours, whose parents differ, and naming one flavour's parents would reject the others. --- src/Migration/Source.php | 60 +++++++++++++++++++++++++++++- src/Migration/Sources/Appwrite.php | 29 +++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/Migration/Source.php b/src/Migration/Source.php index 837c23aa..0b14b2a5 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -21,6 +21,17 @@ public function supportsDatabaseStatus(): bool return false; } + /** + * Resources this source cannot transfer unless their prerequisites travel + * with them, keyed by resource type. + * + * @return array> + */ + public function getResourceDependencies(): array + { + return []; + } + public function getAuthBatchSize(): int { return static::$defaultBatchSize; @@ -160,8 +171,9 @@ public function runWithResourceSelector( */ public function exportResources(array $resources): void { + $requested = $resources; $groups = []; - foreach ($resources as $resource) { + foreach ($requested as $resource) { $mapping = [ Transfer::GROUP_AUTH => Transfer::GROUP_AUTH_RESOURCES, Transfer::GROUP_DATABASES => Transfer::GROUP_DATABASES_RESOURCES, @@ -187,6 +199,8 @@ public function exportResources(array $resources): void return; } + $this->reportMissingDependencies($requested, $groups); + foreach ($groups as $group => $resources) { switch ($group) { case Transfer::GROUP_AUTH: @@ -223,6 +237,50 @@ public function exportResources(array $resources): void } } + /** + * Record an error for every requested resource whose prerequisites are absent. + * + * A resource that outlives its prerequisites is not an error on its own: the + * exporter walks a cache the missing prerequisite never filled, and the + * transfer finishes reporting success having moved nothing. Naming what is + * missing turns that into a failure someone can act on. + * + * @param array $requested + * @param array> $groups + */ + private function reportMissingDependencies(array $requested, array $groups): void + { + $groupOf = []; + foreach ($groups as $group => $resources) { + foreach ($resources as $resource) { + $groupOf[$resource] = $group; + } + } + + foreach ($this->getResourceDependencies() as $resource => $requires) { + if (!\in_array($resource, $requested, true)) { + continue; + } + + $missing = \array_values(\array_diff($requires, $requested)); + + if (empty($missing)) { + continue; + } + + $this->addError(new Exception( + $resource, + $groupOf[$resource] ?? Transfer::GROUP_GENERAL, + message: \sprintf( + 'Cannot transfer %s without %s.', + $resource, + \implode(' and ', $missing) + ), + code: Exception::CODE_VALIDATION, + )); + } + } + /** * Export Auth Group * diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 61443459..cd521857 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -323,6 +323,35 @@ public static function getSupportedResources(): array ]; } + /** + * Prerequisites each resource needs in the same request. Every exporter here + * either walks its parent out of the transfer cache, or is emitted inline by + * the parent's own exporter, so a request naming the child alone moves + * nothing at all. + * + * Index, collection, attribute and document are deliberately absent. They + * are shared between the tables, documents and vectors flavours, whose + * parents differ, and naming one flavour's parents would reject the others. + * + * @return array> + */ + #[Override] + public function getResourceDependencies(): array + { + return [ + Resource::TYPE_MEMBERSHIP => [Resource::TYPE_USER, Resource::TYPE_TEAM], + Resource::TYPE_SUBSCRIBER => [Resource::TYPE_TOPIC, Resource::TYPE_USER], + Resource::TYPE_TABLE => [Resource::TYPE_DATABASE], + Resource::TYPE_COLUMN => [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], + Resource::TYPE_ROW => [Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN], + Resource::TYPE_FILE => [Resource::TYPE_BUCKET], + Resource::TYPE_ENVIRONMENT_VARIABLE => [Resource::TYPE_FUNCTION], + Resource::TYPE_DEPLOYMENT => [Resource::TYPE_FUNCTION], + Resource::TYPE_SITE_VARIABLE => [Resource::TYPE_SITE], + Resource::TYPE_SITE_DEPLOYMENT => [Resource::TYPE_SITE], + ]; + } + /** * @return int */ From c8bf05a25e4c73b5c2cd49ede2d3da40a381db3d Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 10 Sep 2026 14:13:26 +0530 Subject: [PATCH 2/5] fix: import SES providers and keep scheduled email attachments createProvider matched eleven provider types and fell through to a throw for Amazon SES, which both lost the provider and failed the whole migration; the SDK has had createSesProvider all along. createScheduledMessage passed null in createEmail's attachments slot, so a future-dated email arrived without its attachments even when the files had been migrated. --- src/Migration/Destinations/Appwrite.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index a8eb59d0..0334d171 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -2896,6 +2896,18 @@ protected function createProvider(Provider $resource): void ($options['replyToEmail'] ?? '') ?: null, $enabled, ), + 'ses' => $this->messaging->createSesProvider( + $id, + $name, + $credentials['accessKey'] ?? null, + $credentials['secretKey'] ?? null, + $credentials['region'] ?? null, + ($options['fromName'] ?? '') ?: null, + ($options['fromEmail'] ?? '') ?: null, + ($options['replyToName'] ?? '') ?: null, + ($options['replyToEmail'] ?? '') ?: null, + $enabled, + ), 'smtp' => $this->messaging->createSMTPProvider( $id, $name, @@ -3158,7 +3170,7 @@ protected function createScheduledMessage(Message $resource, array $resolvedTarg $targets, $data['cc'] ?? null, $data['bcc'] ?? null, - null, + $data['attachments'] ?? null, false, $data['html'] ?? null, $scheduledAt, From df77d69061dd44acb0d9119f3a7191e2dc91b9ad Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 10 Sep 2026 14:13:26 +0530 Subject: [PATCH 3/5] test: cover requests missing a prerequisite Assert that an incomplete request is reported and names only what is actually absent, since the untreated case is silent and a transfer that moves nothing still reports success. --- tests/Migration/Unit/Adapters/MockSource.php | 12 +++ .../Unit/General/ResourceDependenciesTest.php | 96 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 tests/Migration/Unit/General/ResourceDependenciesTest.php diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index bd62b3ce..5db5317a 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -11,6 +11,18 @@ class MockSource extends Source { private array $mockResources = []; + private array $resourceDependencies = []; + + public function setResourceDependencies(array $dependencies): void + { + $this->resourceDependencies = $dependencies; + } + + public function getResourceDependencies(): array + { + return $this->resourceDependencies; + } + private ?string $resourceChildId = null; #[Override] diff --git a/tests/Migration/Unit/General/ResourceDependenciesTest.php b/tests/Migration/Unit/General/ResourceDependenciesTest.php new file mode 100644 index 00000000..88167e2d --- /dev/null +++ b/tests/Migration/Unit/General/ResourceDependenciesTest.php @@ -0,0 +1,96 @@ +source = new MockSource(); + $this->destination = new MockDestination(); + + $this->transfer = new Transfer( + $this->source, + $this->destination + ); + + $this->source->setResourceDependencies([ + Resource::TYPE_MEMBERSHIP => [Resource::TYPE_USER, Resource::TYPE_TEAM], + ]); + + $team = new Team('team', 'Team'); + $user = new User('user', 'user@example.com'); + + $this->source->pushMockResource($team); + $this->source->pushMockResource($user); + $this->source->pushMockResource(new Membership('membership', $team, $user)); + } + + public function testMissingPrerequisitesAreReported(): void + { + $this->transfer->run([Resource::TYPE_MEMBERSHIP], function () {}); + + $errors = $this->source->getErrors(); + + $this->assertCount(1, $errors); + $this->assertSame(Resource::TYPE_MEMBERSHIP, $errors[0]->getResourceName()); + $this->assertSame( + 'Cannot transfer membership without user and team.', + $errors[0]->getMessage() + ); + } + + public function testOnlyTheAbsentPrerequisitesAreNamed(): void + { + $this->transfer->run( + [Resource::TYPE_USER, Resource::TYPE_MEMBERSHIP], + function () {} + ); + + $errors = $this->source->getErrors(); + + $this->assertCount(1, $errors); + $this->assertSame( + 'Cannot transfer membership without team.', + $errors[0]->getMessage() + ); + } + + public function testASatisfiedRequestIsNotReported(): void + { + $this->transfer->run( + [Resource::TYPE_USER, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP], + function () {} + ); + + $this->assertEmpty($this->source->getErrors()); + } + + public function testAResourceWithNoPrerequisitesIsNotReported(): void + { + $this->transfer->run([Resource::TYPE_USER], function () {}); + + $this->assertEmpty($this->source->getErrors()); + } +} From 331e7a900868de0b3ff1624f5c550183af084085 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 10 Sep 2026 14:17:21 +0530 Subject: [PATCH 4/5] refactor: tidy the dependency check's phrasing and mock layout Say plainly what the check is for, and cover the case where a resource is emitted by its prerequisite's exporter rather than read from the cache. Keep MockSource's properties together and mark the override. --- src/Migration/Source.php | 9 +++++---- tests/Migration/Unit/Adapters/MockSource.php | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Migration/Source.php b/src/Migration/Source.php index 0b14b2a5..336035ba 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -240,10 +240,11 @@ public function exportResources(array $resources): void /** * Record an error for every requested resource whose prerequisites are absent. * - * A resource that outlives its prerequisites is not an error on its own: the - * exporter walks a cache the missing prerequisite never filled, and the - * transfer finishes reporting success having moved nothing. Naming what is - * missing turns that into a failure someone can act on. + * A resource requested without its prerequisites is not an error on its own: + * its exporter walks a cache the missing prerequisite never filled, or is + * only reached by the prerequisite's own exporter. Neither path raises + * anything, so the transfer finishes reporting success having moved none of + * it. Naming what is absent turns that into a failure someone can act on. * * @param array $requested * @param array> $groups diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index 5db5317a..63c0509b 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -13,18 +13,19 @@ class MockSource extends Source private array $resourceDependencies = []; + private ?string $resourceChildId = null; + public function setResourceDependencies(array $dependencies): void { $this->resourceDependencies = $dependencies; } + #[Override] public function getResourceDependencies(): array { return $this->resourceDependencies; } - private ?string $resourceChildId = null; - #[Override] public function run( array $resources, From b706748154ff6a466b8dfc1f36f1282edfdaa316 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 10 Sep 2026 14:20:14 +0530 Subject: [PATCH 5/5] style: close empty closures the way the suite does --- .../Unit/General/ResourceDependenciesTest.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/Migration/Unit/General/ResourceDependenciesTest.php b/tests/Migration/Unit/General/ResourceDependenciesTest.php index 88167e2d..08023bef 100644 --- a/tests/Migration/Unit/General/ResourceDependenciesTest.php +++ b/tests/Migration/Unit/General/ResourceDependenciesTest.php @@ -49,7 +49,8 @@ public function setup(): void public function testMissingPrerequisitesAreReported(): void { - $this->transfer->run([Resource::TYPE_MEMBERSHIP], function () {}); + $this->transfer->run([Resource::TYPE_MEMBERSHIP], function () { + }); $errors = $this->source->getErrors(); @@ -65,7 +66,8 @@ public function testOnlyTheAbsentPrerequisitesAreNamed(): void { $this->transfer->run( [Resource::TYPE_USER, Resource::TYPE_MEMBERSHIP], - function () {} + function () { + } ); $errors = $this->source->getErrors(); @@ -81,7 +83,8 @@ public function testASatisfiedRequestIsNotReported(): void { $this->transfer->run( [Resource::TYPE_USER, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP], - function () {} + function () { + } ); $this->assertEmpty($this->source->getErrors()); @@ -89,7 +92,8 @@ function () {} public function testAResourceWithNoPrerequisitesIsNotReported(): void { - $this->transfer->run([Resource::TYPE_USER], function () {}); + $this->transfer->run([Resource::TYPE_USER], function () { + }); $this->assertEmpty($this->source->getErrors()); }