From ff3b4445e7935b3468ef63cdff0ae4f115378bb0 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 8 May 2026 14:12:08 +1200 Subject: [PATCH 1/4] fix(destination): stop propagating source DSN to destination database documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore an opt-in resolver for `_databases.database` on the Appwrite destination so cross-instance migrations don't write the source's DSN into the destination project's metadata. Without a resolver, the field is left blank and the runtime falls back to the destination project's DSN — correct for legacy single-DSN projects. Why: PR #151 removed the `getDatabaseDSN` callable on the assumption it was unused, and replaced it with `\$resource->getDatabase()` (the source DSN). On Cloud this routed destination reads to the source's host with the destination's project sequence as the namespace, producing `Table 'appwrite.___metadata' doesn't exist` for every migrated database. Multi-type setups (documentsdb / vectorsdb) were silently broken the same way. Refs PR #151. --- src/Migration/Destinations/Appwrite.php | 34 ++++++- .../AppwriteDestinationDsnTest.php | 95 +++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index c47b9cdc..fa464e11 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -103,6 +103,17 @@ class Appwrite extends Destination */ protected $getDatabasesDB; + /** + * Resolves the DSN written into the destination's `_databases.database` for + * a migrated database. When unset, the attribute is left blank and the + * runtime falls back to the destination project's DSN — correct for legacy + * single-DSN projects, but cross-instance / multi-type setups must inject + * a resolver so source DSNs don't bleed into the destination metadata. + * + * @var (callable(Database $resource): string)|null + */ + protected $getDatabaseDSN; + /** * @var array */ @@ -139,6 +150,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. Required for cross-instance migrations to prevent the source DSN from being written into the destination project's metadata. */ public function __construct( string $project, @@ -148,6 +160,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 +179,21 @@ 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, since cross-instance source/destination DSNs differ and + * propagation routes destination reads to the wrong host (see PR #151). + */ + 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 +547,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 +569,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() + // Source and destination can be in different locations; never write the source DSN here. + 'database' => $this->resolveDestinationDsn($resource), ])); $resource->setSequence($database->getSequence()); diff --git a/tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php b/tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php new file mode 100644 index 00000000..892868db --- /dev/null +++ b/tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php @@ -0,0 +1,95 @@ +__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 (): 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; + } +} From 8a0ebdbed72466a362de2580d06742cea6daff3a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 8 May 2026 17:39:28 +1200 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20clarify=20resolver=20scope=20?= =?UTF-8?q?=E2=80=94=20when=20source/destination=20DSNs=20differ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Cross-instance migrations" was ambiguous: it read like cloud-to-cloud or dedicated-DB migration. The actual scope is narrower — any case where the source project's stored DSN doesn't apply to the destination project (cross-host within the same instance, cross-region, mixed shared-tables migration). Reword the property, constructor, and inline write-site comments to say that explicitly. --- src/Migration/Destinations/Appwrite.php | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index fa464e11..a34e164b 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -104,11 +104,13 @@ class Appwrite extends Destination protected $getDatabasesDB; /** - * Resolves the DSN written into the destination's `_databases.database` for - * a migrated database. When unset, the attribute is left blank and the - * runtime falls back to the destination project's DSN — correct for legacy - * single-DSN projects, but cross-instance / multi-type setups must inject - * a resolver so source DSNs don't bleed into the destination metadata. + * 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 */ @@ -150,7 +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. Required for cross-instance migrations to prevent the source DSN from being written into the destination project's metadata. + * @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, @@ -185,8 +187,9 @@ public function __construct( /** * Resolve the DSN written into the destination's `_databases.database`. * Without a resolver, leave it blank — the source DSN must never be - * propagated, since cross-instance source/destination DSNs differ and - * propagation routes destination reads to the wrong host (see PR #151). + * 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 { @@ -569,7 +572,7 @@ 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 locations; never write the source DSN here. + // Resolved by the destination's resolver (or left blank); never copy the source's DSN by default. 'database' => $this->resolveDestinationDsn($resource), ])); From 2eefb590346ce4d1667cf979a8345f516cd3af6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 06:04:53 +0000 Subject: [PATCH 3/4] fix(test): add UtopiaDocument parameter to getDatabasesDB closure signature Agent-Logs-Url: https://github.com/utopia-php/migration/sessions/d317b600-9533-45b9-b24b-59eb2df45014 Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- .../Migration/Unit/Destinations/AppwriteDestinationDsnTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php b/tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php index 892868db..e6e9e4d8 100644 --- a/tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php +++ b/tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use ReflectionClass; use Utopia\Database\Database as UtopiaDatabase; +use Utopia\Database\Document as UtopiaDocument; use Utopia\Migration\Destinations\Appwrite as AppwriteDestination; use Utopia\Migration\Destinations\OnDuplicate; use Utopia\Migration\Resources\Database\Database as DatabaseResource; @@ -68,7 +69,7 @@ private function makeDestination(?callable $getDatabaseDSN): AppwriteDestination endpoint: 'http://example.test/v1', key: 'test-key', dbForProject: $this->createStub(UtopiaDatabase::class), - getDatabasesDB: fn (): UtopiaDatabase => $this->createStub(UtopiaDatabase::class), + getDatabasesDB: fn (UtopiaDocument $database): UtopiaDatabase => $this->createStub(UtopiaDatabase::class), collectionStructure: ['attributes' => [], 'indexes' => []], onDuplicate: OnDuplicate::Fail, getDatabaseDSN: $getDatabaseDSN, From 9c9df8f1eb12c3fb1fa98f4749005190ccd279b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 06:07:13 +0000 Subject: [PATCH 4/4] fix(destination): use resolveDestinationDsn in databaseSpecMatches instead of source DSN Agent-Logs-Url: https://github.com/utopia-php/migration/sessions/f7af1371-ec8c-49fd-bb9e-932ae59e7d6e Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- src/Migration/Destinations/Appwrite.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index a34e164b..312f7e3f 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -1610,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