Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions src/Migration/Destinations/Appwrite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<UtopiaDocument>
*/
Expand Down Expand Up @@ -139,6 +152,7 @@ class Appwrite extends Destination
* @param callable(UtopiaDocument $database):UtopiaDatabase $getDatabasesDB
* @param array<array<string, mixed>> $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,
Expand All @@ -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;
Expand All @@ -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. */
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand Down Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions tests/Migration/Unit/Destinations/AppwriteDestinationDsnTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace Utopia\Tests\Unit\Destinations;

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;

/**
* Regression for PR #151: the destination must never write the source's DSN
* into `_databases.database`. With no resolver, the value must be blank so
* the runtime falls back to the destination project's DSN. With a resolver,
* the resolver's value must be written and the source's value ignored.
*
* Reproduces the comuneo-pre-production incident where post-migration
* `_databases.database` rows pointed at the source's host (db11) and
* destination reads hit `Table 'appwrite._<tenant>__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,
);
Comment thread
abnegate marked this conversation as resolved.
}

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');
Comment thread
abnegate marked this conversation as resolved.
/** @var string $value */
$value = $method->invoke($destination, $resource);
return $value;
}
Comment thread
abnegate marked this conversation as resolved.
}
Loading