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
194 changes: 171 additions & 23 deletions src/Migration/Destinations/Appwrite.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ class Appwrite extends Destination
private const META_ATTRIBUTES = 'attributes';
private const META_INDEXES = 'indexes';

/** A database is provisioning while its resources transfer, ready once the run completes, or failed if creation errored. */
private const DATABASE_STATUS_PROVISIONING = 'provisioning';
private const DATABASE_STATUS_READY = 'ready';
private const DATABASE_STATUS_FAILED = 'failed';

/** Attribute fields the SDK can't update in place (no per-type updateX endpoint exposes them); a change here forces drop+recreate. */
private const ATTRIBUTE_IMMUTABLE_FIELDS = [
'type',
Expand Down Expand Up @@ -164,6 +169,14 @@ class Appwrite extends Destination
*/
private array $processedTwoWayPairs = [];

/**
* Databases created this run, held in `provisioning` until the run finishes
* successfully. The end-of-run sweep flips each to `ready`. Keyed by database id.
*
* @var array<string, true>
*/
private array $provisioningDatabases = [];

/**
* @param string $project
* @param string $endpoint
Expand Down Expand Up @@ -223,6 +236,18 @@ private function resolveDestinationDsn(Database $resource): string
return ($this->getDatabaseDSN)($resource);
}

private function getSupportForDatabaseStatus(): bool
{
// $source is a non-nullable typed property with no default; it is only set when the
// destination runs through Transfer. Guard so direct createDatabase() calls (e.g. tests)
// don't hit "must not be accessed before initialization".
if (! isset($this->source)) {
return false;
}

return $this->getSource()->supportsDatabaseStatus();
}

/** Orphan cleanup runs only after a successful migration — a mid-run throw preserves the destination as-is. */
#[Override]
public function run(
Expand All @@ -233,6 +258,9 @@ public function run(
): void {
$this->resetRunState();
parent::run($resources, $callback, $rootResourceId, $rootResourceType);
// parent::run() returning means every resource transferred, so the databases are usable.
// Flip status before the orphan sweep so a cleanup failure can't strand them in `provisioning`.
$this->markProvisionedDatabasesReady();
Comment thread
greptile-apps[bot] marked this conversation as resolved.
$this->cleanupOverwriteOrphans();
}

Expand All @@ -242,6 +270,41 @@ private function resetRunState(): void
$this->rowBuffer = [];
$this->orphansByTable = [];
$this->processedTwoWayPairs = [];
$this->provisioningDatabases = [];
}

private function markProvisionedDatabasesReady(): void
{
foreach (\array_keys($this->provisioningDatabases) as $databaseId) {
try {
$this->setDatabaseStatus($databaseId, self::DATABASE_STATUS_READY);
} catch (\Throwable) {
// Best-effort: a transient error on one database must not strand the rest
// in provisioning or block the orphan-cleanup sweep that follows.
}
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

private function setDatabaseStatus(string $databaseId, string $status): void
{
if (! $this->getSupportForDatabaseStatus()) {
return;
}

$this->dbForProject->updateDocument(
self::META_DATABASES,
$databaseId,
new UtopiaDocument(['status' => $status]),
);
}

/** Best-effort transition to `failed`; a secondary error here must not mask the caller's original throw. */
private function markDatabaseFailed(string $databaseId): void
{
try {
$this->setDatabaseStatus($databaseId, self::DATABASE_STATUS_FAILED);
} catch (\Throwable) {
}
}

public static function getName(): string
Expand Down Expand Up @@ -508,13 +571,21 @@ protected function import(array $resources, callable $callback): void
*/
public function importDatabaseResource(Resource $resource, bool $isLast): Resource
{
$success = false;

switch ($resource->getName()) {
case Resource::TYPE_DATABASE:
case Resource::TYPE_DATABASE_DOCUMENTSDB:
case Resource::TYPE_DATABASE_VECTORSDB:
/** @var Database $resource */
$success = $this->createDatabase($resource);
break;
try {
$this->dbForProject->setPreserveDates(false);
/** @var Database $resource */
$success = $this->createDatabase($resource);
break;
} finally {
$this->dbForProject->setPreserveDates(true);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment on lines +580 to +587

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 setPreserveDates(false) silently discards source timestamps for the entire database creation path

Before this PR, the outer resource-processing loop set setPreserveDates(true) before calling importDatabaseResource, so createDatabase ran with date preservation active and the explicitly computed $createdAt/$updatedAt (lines 644–645, 744–747) were written to the destination. This PR wraps the database case in setPreserveDates(false), which means the $createdAt/$updatedAt fields in the createDocument call — and the $updatedAt in the Overwrite path's updateDocument — are all overridden with the current wall-clock time. Migrated databases will show the migration run time instead of the original source creation/update time, silently breaking the timestamp-preservation contract that every other resource type still honours. If the intent is only to suppress date preservation for the createCollection system-collection call, that call should be wrapped independently rather than wrapping all of createDatabase.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Migration/Destinations/Appwrite.php
Line: 580-587

Comment:
**`setPreserveDates(false)` silently discards source timestamps for the entire database creation path**

Before this PR, the outer resource-processing loop set `setPreserveDates(true)` before calling `importDatabaseResource`, so `createDatabase` ran with date preservation active and the explicitly computed `$createdAt`/`$updatedAt` (lines 644–645, 744–747) were written to the destination. This PR wraps the database case in `setPreserveDates(false)`, which means the `$createdAt`/`$updatedAt` fields in the `createDocument` call — and the `$updatedAt` in the Overwrite path's `updateDocument` — are all overridden with the current wall-clock time. Migrated databases will show the migration run time instead of the original source creation/update time, silently breaking the timestamp-preservation contract that every other resource type still honours. If the intent is only to suppress date preservation for the `createCollection` system-collection call, that call should be wrapped independently rather than wrapping all of `createDatabase`.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its intentional for the database

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it — makes sense that database-level timestamp handling differs from other resources. Thanks for the clarification!


case Resource::TYPE_TABLE:
case Resource::TYPE_COLLECTION:
/** @var Table $resource */
Expand Down Expand Up @@ -580,28 +651,84 @@ protected function createDatabase(Database $resource): bool
$updatedAt,
$existing->getUpdatedAt(),
);
// Spec match → skip work. Create excluded; nothing on dest to match against.
if ($action !== SchemaAction::Create && $this->databaseSpecMatches($existing, $resource)) {

$isFailed = ! $existing->isEmpty()
&& $this->getSupportForDatabaseStatus()
&& $existing->getAttribute('status') === self::DATABASE_STATUS_FAILED;
Comment thread
greptile-apps[bot] marked this conversation as resolved.

if ($isFailed) {
// A prior run created the metadata document but left the database unusable (its backing
// collection may be missing). Force Overwrite — regardless of timestamps or spec match —
// so the recovery path recreates the collection instead of skipping it forever.
$action = SchemaAction::Overwrite;
} elseif ($action !== SchemaAction::Create && $this->databaseSpecMatches($existing, $resource)) {
// Spec match → skip work. Create excluded; nothing on dest to match against.
$action = SchemaAction::Skip;
}

$earlyReturn = match ($action) {
SchemaAction::Skip => (function () use ($resource, $existing): bool {
$resource->setSequence($existing->getSequence());
$resource->setStatus(Resource::STATUS_SKIPPED, 'Already exists on destination');
// Recover a database left in `provisioning` by a prior failed run: the spec matches so
// we skip re-import, but the end-of-run sweep should still flip it to `ready`.
if (
$this->getSupportForDatabaseStatus()
&& $existing->getAttribute('status') === self::DATABASE_STATUS_PROVISIONING
) {
$this->provisioningDatabases[$resource->getId()] = true;
}
return false;
})(),
SchemaAction::Overwrite => (function () use ($resource, $existing, $updatedAt): bool {
$this->dbForProject->updateDocument(self::META_DATABASES, $existing->getId(), new UtopiaDocument([
SchemaAction::Overwrite => (function () use ($resource, $existing, $updatedAt, $isFailed): bool {
$document = [
'name' => $resource->getDatabaseName(),
'search' => implode(' ', [$resource->getId(), $resource->getDatabaseName()]),
'enabled' => $resource->getEnabled(),
'type' => empty($resource->getType()) ? 'legacy' : $resource->getType(),
'originalId' => empty($resource->getOriginalId()) ? null : $resource->getOriginalId(),
'database' => $this->resolveDestinationDsn($resource),
'$updatedAt' => $updatedAt,
]));
];

if ($this->getSupportForDatabaseStatus()) {
$document['status'] = self::DATABASE_STATUS_PROVISIONING;
}

$this->dbForProject->updateDocument(self::META_DATABASES, $existing->getId(), new UtopiaDocument($document));
$resource->setSequence($existing->getSequence());

// Only a `failed` database can be missing its backing collection (a prior run wrote the
// metadata document but threw before createCollection). Recreate it so we never flip a
// database to ready with no collection behind it. A healthy overwrite already has its
// collection, so we skip the lookup entirely.
if ($isFailed && $this->dbForProject->getCollection($this->databaseCollectionId($existing))->isEmpty()) {
try {
$columns = \array_map(
fn ($attr) => new UtopiaDocument($attr),
$this->collectionStructure['attributes']
);

$indexes = \array_map(
fn ($index) => new UtopiaDocument($index),
$this->collectionStructure['indexes']
);

$this->dbForProject->createCollection(
$this->databaseCollectionId($existing),
$columns,
$indexes
);
} catch (\Throwable $e) {
$this->markDatabaseFailed($resource->getId());
throw $e;
}
}

if ($this->getSupportForDatabaseStatus()) {
$this->provisioningDatabases[$resource->getId()] = true;
}

return true;
})(),
SchemaAction::Create => null,
Expand All @@ -611,7 +738,7 @@ protected function createDatabase(Database $resource): bool
}
}

$database = $this->dbForProject->createDocument(self::META_DATABASES, new UtopiaDocument([
$document = [
'$id' => $resource->getId(),
'name' => $resource->getDatabaseName(),
'enabled' => $resource->getEnabled(),
Expand All @@ -622,25 +749,45 @@ protected function createDatabase(Database $resource): bool
'type' => empty($resource->getType()) ? 'legacy' : $resource->getType(),
// Resolved by the destination's resolver (or left blank); never copy the source's DSN by default.
'database' => $this->resolveDestinationDsn($resource),
]));
];

// Only SOURCE_DATABASE Appwrite migrations manage the status field: create in `provisioning`,
// then markProvisionedDatabasesReady() flips it to `ready` once the run finishes. Every other
// source leaves status untouched so the collection default applies. Never copy the source's state.
if ($this->getSupportForDatabaseStatus()) {
$document['status'] = self::DATABASE_STATUS_PROVISIONING;
}

$database = $this->dbForProject->createDocument(self::META_DATABASES, new UtopiaDocument($document));

$resource->setSequence($database->getSequence());

$columns = \array_map(
fn ($attr) => new UtopiaDocument($attr),
$this->collectionStructure['attributes']
);
try {
$columns = \array_map(
fn ($attr) => new UtopiaDocument($attr),
$this->collectionStructure['attributes']
);

$indexes = \array_map(
fn ($index) => new UtopiaDocument($index),
$this->collectionStructure['indexes']
);
$indexes = \array_map(
fn ($index) => new UtopiaDocument($index),
$this->collectionStructure['indexes']
);

$this->dbForProject->createCollection(
$this->databaseCollectionId($database),
$columns,
$indexes
);
$this->dbForProject->createCollection(
$this->databaseCollectionId($database),
$columns,
$indexes
);
} catch (\Throwable $e) {
// The metadata document exists but the database isn't usable; mark it failed before propagating.
$this->markDatabaseFailed($resource->getId());
throw $e;
}

// Fully created; when we manage status it stays provisioning until the end-of-run sweep flips it to ready.
if ($this->getSupportForDatabaseStatus()) {
$this->provisioningDatabases[$resource->getId()] = true;
}

return true;
}
Expand Down Expand Up @@ -3271,6 +3418,7 @@ protected function createRule(Rule $resource): bool
302 => StatusCode::FOUND(),
307 => StatusCode::TEMPORARYREDIRECT(),
308 => StatusCode::PERMANENTREDIRECT(),
// no break
default => StatusCode::MOVEDPERMANENTLY(),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
};

Expand Down
17 changes: 13 additions & 4 deletions src/Migration/Resources/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ public function __construct(
protected bool $enabled = true,
protected string $originalId = '',
protected string $type = '',
protected ?string $database = ''
protected ?string $database = '',
protected ?string $databaseStatus = null
) {
$this->id = $id;
}
Expand All @@ -39,7 +40,8 @@ public function __construct(
* updatedAt: string,
* enabled: bool,
* originalId: string|null,
* database: string
* database: string,
* status: string|null
* } $array
*/
public static function fromArray(array $array): self
Expand All @@ -52,7 +54,8 @@ public static function fromArray(array $array): self
enabled: $array['enabled'] ?? true,
originalId: $array['originalId'] ?? '',
type: $array['type'] ?? 'legacy',
database: $array['database'] ?? null
database: $array['database'] ?? null,
databaseStatus: $array['status'] ?? null
);
}

Expand All @@ -68,7 +71,8 @@ public function jsonSerialize(): array
'updatedAt' => $this->updatedAt,
'enabled' => $this->enabled,
'type' => $this->type,
'database' => $this->database
'database' => $this->database,
'status' => $this->databaseStatus
];
}

Expand Down Expand Up @@ -101,4 +105,9 @@ public function getDatabase(): ?string
{
return $this->database;
}

public function getDatabaseStatus(): ?string
{
return $this->databaseStatus;
}
}
6 changes: 4 additions & 2 deletions src/Migration/Resources/Database/DocumentsDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ public static function getName(): string
* updatedAt: string,
* enabled: bool,
* originalId: string|null,
* database: string
* database: string,
* status: string|null
* } $array
*/
public static function fromArray(array $array): self
Expand All @@ -32,7 +33,8 @@ public static function fromArray(array $array): self
enabled: $array['enabled'] ?? true,
originalId: $array['originalId'] ?? '',
type: $array['type'] ?? Resource::TYPE_DATABASE_DOCUMENTSDB,
database: $array['database']
database: $array['database'],
databaseStatus: $array['status'] ?? null
);
}
}
6 changes: 4 additions & 2 deletions src/Migration/Resources/Database/VectorsDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public static function getName(): string
* enabled: bool,
* originalId: string|null,
* database: string,
* type: string
* type: string,
* status: string|null
* } $array
*/
public static function fromArray(array $array): self
Expand All @@ -33,7 +34,8 @@ public static function fromArray(array $array): self
enabled: $array['enabled'] ?? true,
originalId: $array['originalId'] ?? '',
type: $array['type'] ?? Resource::TYPE_DATABASE_VECTORSDB,
database: $array['database']
database: $array['database'],
databaseStatus: $array['status'] ?? null
);
}
}
5 changes: 5 additions & 0 deletions src/Migration/Source.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ abstract class Source extends Target
*/
public array $previousReport = [];

public function supportsDatabaseStatus(): bool
{
return false;
}

public function getAuthBatchSize(): int
{
return static::$defaultBatchSize;
Expand Down
Loading
Loading