diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index c47b9cdc..312f7e3f 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -103,6 +103,19 @@ class Appwrite extends Destination */ protected $getDatabasesDB; + /** + * Resolves the DSN written into the destination's `_databases.database` + * for a migrated database. When the source and destination projects don't + * share the same DSN — e.g. one project is on a host the other isn't — + * pass a resolver so the destination metadata carries its own DSN instead + * of the source's. When unset, the attribute is left blank and the + * runtime falls back to the destination project's DSN at read time, which + * is safe for single-host single-type setups. + * + * @var (callable(Database $resource): string)|null + */ + protected $getDatabaseDSN; + /** * @var array */ @@ -139,6 +152,7 @@ class Appwrite extends Destination * @param callable(UtopiaDocument $database):UtopiaDatabase $getDatabasesDB * @param array> $collectionStructure * @param OnDuplicate $onDuplicate Behavior when a row with an existing $id is encountered. + * @param (callable(Database $resource): string)|null $getDatabaseDSN Resolver for the destination's `_databases.database` value. Pass when the destination project's DSN differs from the source's, so the destination row carries its own DSN instead of inheriting the source's. */ public function __construct( string $project, @@ -148,6 +162,7 @@ public function __construct( callable $getDatabasesDB, protected array $collectionStructure, protected OnDuplicate $onDuplicate = OnDuplicate::Fail, + ?callable $getDatabaseDSN = null, ) { $this->project = $project; $this->endpoint = $endpoint; @@ -166,6 +181,22 @@ public function __construct( $this->users = new Users($this->client); $this->getDatabasesDB = $getDatabasesDB; + $this->getDatabaseDSN = $getDatabaseDSN; + } + + /** + * Resolve the DSN written into the destination's `_databases.database`. + * Without a resolver, leave it blank — the source DSN must never be + * propagated as the default, since when source and destination DSNs + * differ propagation routes destination reads to the wrong host (the + * regression PR #151 introduced). + */ + private function resolveDestinationDsn(Database $resource): string + { + if ($this->getDatabaseDSN === null) { + return ''; + } + return ($this->getDatabaseDSN)($resource); } /** Orphan cleanup runs only after a successful migration — a mid-run throw preserves the destination as-is. */ @@ -519,7 +550,7 @@ protected function createDatabase(Database $resource): bool 'enabled' => $resource->getEnabled(), 'type' => empty($resource->getType()) ? 'legacy' : $resource->getType(), 'originalId' => empty($resource->getOriginalId()) ? null : $resource->getOriginalId(), - 'database' => $resource->getDatabase(), + 'database' => $this->resolveDestinationDsn($resource), '$updatedAt' => $updatedAt, ])); $resource->setSequence($existing->getSequence()); @@ -541,8 +572,8 @@ protected function createDatabase(Database $resource): bool '$updatedAt' => $updatedAt, 'originalId' => empty($resource->getOriginalId()) ? null : $resource->getOriginalId(), 'type' => empty($resource->getType()) ? 'legacy' : $resource->getType(), - // source and destination can be in different location - 'database' => $resource->getDatabase() + // Resolved by the destination's resolver (or left blank); never copy the source's DSN by default. + 'database' => $this->resolveDestinationDsn($resource), ])); $resource->setSequence($database->getSequence()); @@ -1579,7 +1610,7 @@ private function databaseSpecMatches(UtopiaDocument $existing, Database $resourc && $existing->getAttribute('enabled') === $resource->getEnabled() && $existing->getAttribute('type') === $sourceType && $existing->getAttribute('originalId') === $sourceOriginalId - && $existing->getAttribute('database') === $resource->getDatabase(); + && $existing->getAttribute('database') === $this->resolveDestinationDsn($resource); } private function tableSpecMatches(UtopiaDocument $existing, Table $resource): bool diff --git a/tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php b/tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php new file mode 100644 index 00000000..e6e9e4d8 --- /dev/null +++ b/tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php @@ -0,0 +1,96 @@ +__metadata' doesn't exist`. + */ +class AppwriteDestinationDsnTest extends TestCase +{ + public function testWithoutResolverReturnsEmptyString(): void + { + $destination = $this->makeDestination(getDatabaseDSN: null); + $resource = $this->makeResource(sourceDsn: 'database_db_fra1_self_hosted_11_0'); + + $resolved = $this->invokeResolver($destination, $resource); + + $this->assertSame('', $resolved, 'Without a resolver the destination must not propagate the source DSN.'); + } + + public function testWithResolverUsesItsReturnValue(): void + { + $expected = 'appwrite://database_db_fra1_self_hosted_17_0?database=appwrite&namespace=_1'; + $destination = $this->makeDestination( + getDatabaseDSN: fn (DatabaseResource $r): string => $expected, + ); + $resource = $this->makeResource(sourceDsn: 'database_db_fra1_self_hosted_11_0'); + + $resolved = $this->invokeResolver($destination, $resource); + + $this->assertSame($expected, $resolved); + $this->assertNotSame($resource->getDatabase(), $resolved, 'Source DSN must not leak through the resolver path.'); + } + + public function testResolverReceivesTheResource(): void + { + $captured = null; + $destination = $this->makeDestination( + getDatabaseDSN: function (DatabaseResource $r) use (&$captured): string { + $captured = $r; + return 'resolved'; + }, + ); + $resource = $this->makeResource(sourceDsn: 'src'); + + $this->invokeResolver($destination, $resource); + + $this->assertSame($resource, $captured); + } + + private function makeDestination(?callable $getDatabaseDSN): AppwriteDestination + { + return new AppwriteDestination( + project: 'destination-project', + endpoint: 'http://example.test/v1', + key: 'test-key', + dbForProject: $this->createStub(UtopiaDatabase::class), + getDatabasesDB: fn (UtopiaDocument $database): UtopiaDatabase => $this->createStub(UtopiaDatabase::class), + collectionStructure: ['attributes' => [], 'indexes' => []], + onDuplicate: OnDuplicate::Fail, + getDatabaseDSN: $getDatabaseDSN, + ); + } + + private function makeResource(string $sourceDsn): DatabaseResource + { + return new DatabaseResource( + id: 'src-database', + name: 'src', + type: 'legacy', + database: $sourceDsn, + ); + } + + private function invokeResolver(AppwriteDestination $destination, DatabaseResource $resource): string + { + $method = (new ReflectionClass(AppwriteDestination::class))->getMethod('resolveDestinationDsn'); + /** @var string $value */ + $value = $method->invoke($destination, $resource); + return $value; + } +}