Skip to content

Commit ff3b444

Browse files
committed
fix(destination): stop propagating source DSN to destination database documents
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._<tenant>__metadata' doesn't exist` for every migrated database. Multi-type setups (documentsdb / vectorsdb) were silently broken the same way. Refs PR #151.
1 parent 759d6d6 commit ff3b444

2 files changed

Lines changed: 126 additions & 3 deletions

File tree

src/Migration/Destinations/Appwrite.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,17 @@ class Appwrite extends Destination
103103
*/
104104
protected $getDatabasesDB;
105105

106+
/**
107+
* Resolves the DSN written into the destination's `_databases.database` for
108+
* a migrated database. When unset, the attribute is left blank and the
109+
* runtime falls back to the destination project's DSN — correct for legacy
110+
* single-DSN projects, but cross-instance / multi-type setups must inject
111+
* a resolver so source DSNs don't bleed into the destination metadata.
112+
*
113+
* @var (callable(Database $resource): string)|null
114+
*/
115+
protected $getDatabaseDSN;
116+
106117
/**
107118
* @var array<UtopiaDocument>
108119
*/
@@ -139,6 +150,7 @@ class Appwrite extends Destination
139150
* @param callable(UtopiaDocument $database):UtopiaDatabase $getDatabasesDB
140151
* @param array<array<string, mixed>> $collectionStructure
141152
* @param OnDuplicate $onDuplicate Behavior when a row with an existing $id is encountered.
153+
* @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.
142154
*/
143155
public function __construct(
144156
string $project,
@@ -148,6 +160,7 @@ public function __construct(
148160
callable $getDatabasesDB,
149161
protected array $collectionStructure,
150162
protected OnDuplicate $onDuplicate = OnDuplicate::Fail,
163+
?callable $getDatabaseDSN = null,
151164
) {
152165
$this->project = $project;
153166
$this->endpoint = $endpoint;
@@ -166,6 +179,21 @@ public function __construct(
166179
$this->users = new Users($this->client);
167180

168181
$this->getDatabasesDB = $getDatabasesDB;
182+
$this->getDatabaseDSN = $getDatabaseDSN;
183+
}
184+
185+
/**
186+
* Resolve the DSN written into the destination's `_databases.database`.
187+
* Without a resolver, leave it blank — the source DSN must never be
188+
* propagated, since cross-instance source/destination DSNs differ and
189+
* propagation routes destination reads to the wrong host (see PR #151).
190+
*/
191+
private function resolveDestinationDsn(Database $resource): string
192+
{
193+
if ($this->getDatabaseDSN === null) {
194+
return '';
195+
}
196+
return ($this->getDatabaseDSN)($resource);
169197
}
170198

171199
/** 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
519547
'enabled' => $resource->getEnabled(),
520548
'type' => empty($resource->getType()) ? 'legacy' : $resource->getType(),
521549
'originalId' => empty($resource->getOriginalId()) ? null : $resource->getOriginalId(),
522-
'database' => $resource->getDatabase(),
550+
'database' => $this->resolveDestinationDsn($resource),
523551
'$updatedAt' => $updatedAt,
524552
]));
525553
$resource->setSequence($existing->getSequence());
@@ -541,8 +569,8 @@ protected function createDatabase(Database $resource): bool
541569
'$updatedAt' => $updatedAt,
542570
'originalId' => empty($resource->getOriginalId()) ? null : $resource->getOriginalId(),
543571
'type' => empty($resource->getType()) ? 'legacy' : $resource->getType(),
544-
// source and destination can be in different location
545-
'database' => $resource->getDatabase()
572+
// Source and destination can be in different locations; never write the source DSN here.
573+
'database' => $this->resolveDestinationDsn($resource),
546574
]));
547575

548576
$resource->setSequence($database->getSequence());
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
namespace Utopia\Tests\Unit\Destinations;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use ReflectionClass;
7+
use Utopia\Database\Database as UtopiaDatabase;
8+
use Utopia\Migration\Destinations\Appwrite as AppwriteDestination;
9+
use Utopia\Migration\Destinations\OnDuplicate;
10+
use Utopia\Migration\Resources\Database\Database as DatabaseResource;
11+
12+
/**
13+
* Regression for PR #151: the destination must never write the source's DSN
14+
* into `_databases.database`. With no resolver, the value must be blank so
15+
* the runtime falls back to the destination project's DSN. With a resolver,
16+
* the resolver's value must be written and the source's value ignored.
17+
*
18+
* Reproduces the comuneo-pre-production incident where post-migration
19+
* `_databases.database` rows pointed at the source's host (db11) and
20+
* destination reads hit `Table 'appwrite._<tenant>__metadata' doesn't exist`.
21+
*/
22+
class AppwriteDestinationDsnTest extends TestCase
23+
{
24+
public function testWithoutResolverReturnsEmptyString(): void
25+
{
26+
$destination = $this->makeDestination(getDatabaseDSN: null);
27+
$resource = $this->makeResource(sourceDsn: 'database_db_fra1_self_hosted_11_0');
28+
29+
$resolved = $this->invokeResolver($destination, $resource);
30+
31+
$this->assertSame('', $resolved, 'Without a resolver the destination must not propagate the source DSN.');
32+
}
33+
34+
public function testWithResolverUsesItsReturnValue(): void
35+
{
36+
$expected = 'appwrite://database_db_fra1_self_hosted_17_0?database=appwrite&namespace=_1';
37+
$destination = $this->makeDestination(
38+
getDatabaseDSN: fn (DatabaseResource $r): string => $expected,
39+
);
40+
$resource = $this->makeResource(sourceDsn: 'database_db_fra1_self_hosted_11_0');
41+
42+
$resolved = $this->invokeResolver($destination, $resource);
43+
44+
$this->assertSame($expected, $resolved);
45+
$this->assertNotSame($resource->getDatabase(), $resolved, 'Source DSN must not leak through the resolver path.');
46+
}
47+
48+
public function testResolverReceivesTheResource(): void
49+
{
50+
$captured = null;
51+
$destination = $this->makeDestination(
52+
getDatabaseDSN: function (DatabaseResource $r) use (&$captured): string {
53+
$captured = $r;
54+
return 'resolved';
55+
},
56+
);
57+
$resource = $this->makeResource(sourceDsn: 'src');
58+
59+
$this->invokeResolver($destination, $resource);
60+
61+
$this->assertSame($resource, $captured);
62+
}
63+
64+
private function makeDestination(?callable $getDatabaseDSN): AppwriteDestination
65+
{
66+
return new AppwriteDestination(
67+
project: 'destination-project',
68+
endpoint: 'http://example.test/v1',
69+
key: 'test-key',
70+
dbForProject: $this->createStub(UtopiaDatabase::class),
71+
getDatabasesDB: fn (): UtopiaDatabase => $this->createStub(UtopiaDatabase::class),
72+
collectionStructure: ['attributes' => [], 'indexes' => []],
73+
onDuplicate: OnDuplicate::Fail,
74+
getDatabaseDSN: $getDatabaseDSN,
75+
);
76+
}
77+
78+
private function makeResource(string $sourceDsn): DatabaseResource
79+
{
80+
return new DatabaseResource(
81+
id: 'src-database',
82+
name: 'src',
83+
type: 'legacy',
84+
database: $sourceDsn,
85+
);
86+
}
87+
88+
private function invokeResolver(AppwriteDestination $destination, DatabaseResource $resource): string
89+
{
90+
$method = (new ReflectionClass(AppwriteDestination::class))->getMethod('resolveDestinationDsn');
91+
/** @var string $value */
92+
$value = $method->invoke($destination, $resource);
93+
return $value;
94+
}
95+
}

0 commit comments

Comments
 (0)