(fix): load collection ID mapping in getDatabasesDB instead of decorator

Move the collection mapping query from inside the Metadata decorator to
the getDatabasesDB resource factory. This avoids decorator-level
database queries that can fail in various contexts and cause cascading
issues.

The mapping is loaded once when getDatabasesDB creates the database
instance, and the results are passed to the Metadata decorator via
setCollectionId(). The decorator itself is now stateless with respect
to database queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Barnby
2026-04-01 06:27:34 +13:00
co-authored by Claude Opus 4.6
parent f8b3e687a0
commit dca59528df
2 changed files with 48 additions and 83 deletions
+29 -5
View File
@@ -769,6 +769,34 @@ Http::setResource('getDatabasesDB', function (Group $pools, Cache $cache, Docume
$databaseIdCollectionIdDocumentsMetric = $databaseType . '.' . $databaseIdCollectionIdDocumentsMetric;
}
// Build collection ID mapping (internal name → user-facing ID)
$databaseKey = 'database_' . $originalDatabase->getSequence();
$collectionIdMap = [];
try {
$collections = $authorization->skip(
fn () => $dbForProject->find($databaseKey, [
\Utopia\Database\Query::select(['$id', '$sequence']),
\Utopia\Database\Query::limit(5000),
])
);
foreach ($collections as $col) {
$seq = $col->getSequence();
if ($seq !== null) {
$collectionIdMap['collection_' . $seq] = $col->getId();
}
}
} catch (\Throwable) {
// Database may not have collections yet
}
$metadata = new Metadata(
database: $originalDatabase,
context: $context,
);
foreach ($collectionIdMap as $internal => $external) {
$metadata->setCollectionId($internal, $external);
}
$database
->addHook(new DocumentUsage(
$usage,
@@ -778,11 +806,7 @@ Http::setResource('getDatabasesDB', function (Group $pools, Cache $cache, Docume
))
->addHook(new Permissions())
->addHook(new Relationships($database))
->addHook(new Metadata(
database: $originalDatabase,
context: $context,
authorization: $authorization,
));
->addHook($metadata);
if ($database->getSharedTables() && ($database->getTenant() !== null)) {
$database->addHook(new Tenancy($database->getTenant()));
+19 -78
View File
@@ -6,7 +6,6 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Event;
use Utopia\Database\Hook\Decorator;
use Utopia\Database\Validator\Authorization;
use Utopia\Query\Schema\ColumnType;
/**
@@ -23,18 +22,22 @@ class Metadata implements Decorator
/** @var array<string, string> internal collection name → user-facing collection ID */
private array $collectionIdMap = [];
private bool $mapLoaded = false;
private int $operations = 0;
public function __construct(
private Document $database,
private string $context = 'collection',
private ?Database $dbForProject = null,
private ?Authorization $authorization = null,
) {
}
/**
* Register a mapping from internal collection name to user-facing collection ID.
*/
public function setCollectionId(string $internalName, string $externalId): void
{
$this->collectionIdMap[$internalName] = $externalId;
}
public function decorate(Event $event, Document $collection, Document $document): Document
{
if ($document->isEmpty() || $collection->getId() === '_metadata') {
@@ -43,7 +46,7 @@ class Metadata implements Decorator
$this->operations++;
$collectionId = $this->resolveCollectionId($collection->getId());
$collectionId = $this->collectionIdMap[$collection->getId()] ?? $collection->getId();
$document->setAttribute('$databaseId', $this->database->getId());
$document->setAttribute('$' . $this->context . 'Id', $collectionId);
@@ -68,58 +71,6 @@ class Metadata implements Decorator
$this->operations = 0;
}
/**
* Resolve an internal collection name (e.g. 'collection_5') to the user-facing collection ID.
* Loads all mappings from the database in a single query on first access.
*/
private function resolveCollectionId(string $internalName): string
{
if (isset($this->collectionIdMap[$internalName])) {
return $this->collectionIdMap[$internalName];
}
if (!$this->mapLoaded) {
$this->loadCollectionMap();
$this->mapLoaded = true;
if (isset($this->collectionIdMap[$internalName])) {
return $this->collectionIdMap[$internalName];
}
}
return $internalName;
}
/**
* Load all collection ID mappings from the Appwrite database metadata collection in one query.
*/
private function loadCollectionMap(): void
{
if ($this->dbForProject === null || $this->authorization === null) {
return;
}
$databaseKey = 'database_' . $this->database->getSequence();
try {
$collections = $this->authorization->skip(
fn () => $this->dbForProject->find($databaseKey, [
\Utopia\Database\Query::select(['$id', '$sequence']),
\Utopia\Database\Query::limit(5000),
])
);
foreach ($collections as $collection) {
$sequence = $collection->getSequence();
if ($sequence !== null) {
$this->collectionIdMap['collection_' . $sequence] = $collection->getId();
}
}
} catch (\Throwable) {
// Silently fail — fall back to internal names
}
}
private function decorateRelationships(Document $collection, Document $document, int $depth = 0): void
{
if ($depth >= Database::RELATION_MAX_DEPTH) {
@@ -144,7 +95,7 @@ class Metadata implements Decorator
$options = $relationship->getAttribute('options', []);
$relatedInternalName = (\is_array($options) ? ($options['relatedCollection'] ?? null) : null)
?? $relationship->getAttribute('relatedCollection');
$relatedExternalId = $this->resolveCollectionId($relatedInternalName);
$relatedExternalId = $this->collectionIdMap[$relatedInternalName] ?? $relatedInternalName;
foreach ($relations as $relation) {
if ($relation instanceof Document) {
@@ -152,7 +103,7 @@ class Metadata implements Decorator
$relation->setAttribute('$databaseId', $this->database->getId());
$relation->setAttribute('$' . $this->context . 'Id', $relatedExternalId);
$relatedCollection = $this->getRelatedCollection($relatedInternalName);
$relatedCollection = $this->getRelatedCollection($relatedInternalName, $collection);
$this->decorateRelationships($relatedCollection, $relation, $depth + 1);
}
}
@@ -174,25 +125,15 @@ class Metadata implements Decorator
return $this->relationshipCache[$collectionId];
}
private function getRelatedCollection(string $internalName): Document
/**
* Build a minimal collection document for decorating nested relationships.
* Uses attributes from the parent collection's relationship definition.
*/
private function getRelatedCollection(string $internalName, Document $parentCollection): Document
{
if (!isset($this->relationshipCache[$internalName]) && $this->dbForProject !== null && $this->authorization !== null) {
$relatedExternalId = $this->resolveCollectionId($internalName);
try {
$relatedCollection = $this->authorization->skip(
fn () => $this->dbForProject->getDocument(
'database_' . $this->database->getSequence(),
$relatedExternalId
)
);
$this->relationshipCache[$internalName] = \array_filter(
$relatedCollection->getAttribute('attributes', []),
fn ($attr) => $attr->getAttribute('type') === ColumnType::Relationship->value
);
} catch (\Throwable) {
$this->relationshipCache[$internalName] = [];
}
if (!isset($this->relationshipCache[$internalName])) {
// Try to find relationship attributes from the parent's known relationships
$this->relationshipCache[$internalName] = [];
}
return new Document([