diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 893bf987..d4cf2db2 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -85,6 +85,25 @@ class Appwrite extends Destination */ private array $rowBuffer = []; + /** + * Upsert-mode orphan tracking, keyed by (database, table). Each entry + * holds the attribute + index keys source declared for that table plus + * the docs/handles needed to reach the destination at cleanup time. + * Orphans are the complement: anything the destination has whose key + * is NOT in this record. Entries are removed from the map after their + * cleanup has run, so the final sweep at end-of-migration only visits + * tables that had no rows. + * + * @var array, + * indexKeys: list, + * }> + */ + private array $orphansByTable = []; + /** * @param string $project * @param string $endpoint @@ -122,6 +141,22 @@ public function __construct( $this->getDatabasesDB = $getDatabasesDB; } + /** + * Upsert-mode orphan cleanup runs after a successful migration only. + * A thrown exception from the source/import loop short-circuits here + * so mid-migration failures preserve the destination as-is. + */ + #[Override] + public function run( + array $resources, + callable $callback, + string $rootResourceId = '', + string $rootResourceType = '', + ): void { + parent::run($resources, $callback, $rootResourceId, $rootResourceType); + $this->cleanupUpsertOrphans(); + } + public static function getName(): string { return 'Appwrite'; @@ -422,6 +457,36 @@ protected function createDatabase(Database $resource): bool $createdAt = $this->normalizeDateTime($resource->getCreatedAt()); $updatedAt = $this->normalizeDateTime($resource->getUpdatedAt(), $createdAt); + // Fail mode skips the pre-check so library's DuplicateException surfaces on re-migration. + if ($this->onDuplicate !== OnDuplicate::Fail) { + $existing = $this->dbForProject->getDocument('databases', $resource->getId()); + $action = $this->onDuplicate->resolveSchemaAction( + !$existing->isEmpty(), + $updatedAt, + $existing->getUpdatedAt(), + ); + + if ($action === SchemaAction::Tolerate) { + $resource->setSequence($existing->getSequence()); + $resource->setStatus(Resource::STATUS_SKIPPED, 'Already exists on destination'); + return false; + } + + if ($action === SchemaAction::UpdateInPlace) { + $this->dbForProject->updateDocument('databases', $existing->getId(), new UtopiaDocument([ + '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' => $resource->getDatabase(), + '$updatedAt' => $updatedAt, + ])); + $resource->setSequence($existing->getSequence()); + return true; + } + } + $database = $this->dbForProject->createDocument('databases', new UtopiaDocument([ '$id' => $resource->getId(), 'name' => $resource->getDatabaseName(), @@ -503,6 +568,41 @@ protected function createEntity(Table $resource): bool $dbForDatabases->create(); } + if ($this->onDuplicate !== OnDuplicate::Fail) { + $existing = $this->dbForProject->getDocument( + 'database_' . $database->getSequence(), + $resource->getId() + ); + $action = $this->onDuplicate->resolveSchemaAction( + !$existing->isEmpty(), + $updatedAt, + $existing->getUpdatedAt(), + ); + + if ($action === SchemaAction::Tolerate) { + $resource->setSequence($existing->getSequence()); + $resource->setStatus(Resource::STATUS_SKIPPED, 'Already exists on destination'); + return false; + } + + if ($action === SchemaAction::UpdateInPlace) { + $this->dbForProject->updateDocument( + 'database_' . $database->getSequence(), + $existing->getId(), + new UtopiaDocument([ + 'name' => $resource->getTableName(), + 'search' => implode(' ', [$resource->getId(), $resource->getTableName()]), + 'enabled' => $resource->getEnabled(), + '$permissions' => Permission::aggregate($resource->getPermissions()), + 'documentSecurity' => $resource->getRowSecurity(), + '$updatedAt' => $updatedAt, + ]) + ); + $resource->setSequence($existing->getSequence()); + return true; + } + } + $table = $this->dbForProject->createDocument('database_' . $database->getSequence(), new UtopiaDocument([ '$id' => $resource->getId(), 'databaseInternalId' => $database->getSequence(), @@ -642,9 +742,53 @@ protected function createField(Column|Attribute $resource): bool $createdAt = $this->normalizeDateTime($resource->getCreatedAt()); $updatedAt = $this->normalizeDateTime($resource->getUpdatedAt(), $createdAt); $dbForDatabases = ($this->getDatabasesDB)($database); + + $this->trackOrphanCandidate($database, $table, 'attributeKeys', $resource->getKey(), $dbForDatabases); + + // Relationships route to UpdateInPlace, not DropAndRecreate: + // utopia's deleteAttribute throws for VAR_RELATIONSHIP, and + // two-way drops would cascade to the partner table's physical + // column. Non-relationship attrs keep DropAndRecreate. + $isRelationship = $type === UtopiaDatabase::VAR_RELATIONSHIP; + + $attributeMetaId = $database->getSequence() . '_' . $table->getSequence() . '_' . $resource->getKey(); + if ($this->onDuplicate !== OnDuplicate::Fail) { + $existingAttr = $this->dbForProject->getDocument('attributes', $attributeMetaId); + $action = $this->onDuplicate->resolveSchemaAction( + !$existingAttr->isEmpty(), + $updatedAt, + $existingAttr->getUpdatedAt(), + canDrop: !$isRelationship, + ); + + if ($action === SchemaAction::Tolerate) { + $this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $table->getId()); + $dbForDatabases->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $table->getSequence()); + $resource->setStatus(Resource::STATUS_SKIPPED, 'Already exists on destination'); + return false; + } + + if ($action === SchemaAction::UpdateInPlace) { + $this->updateRelationshipInPlace( + $database, + $table, + $resource, + $type, + $updatedAt, + $existingAttr, + $dbForDatabases, + ); + return true; + } + + if ($action === SchemaAction::DropAndRecreate) { + $this->dropAttributeForRecreate($database, $table, $resource, $dbForDatabases); + } + } + try { $column = new UtopiaDocument([ - '$id' => ID::custom($database->getSequence() . '_' . $table->getSequence() . '_' . $resource->getKey()), + '$id' => ID::custom($attributeMetaId), 'key' => $resource->getKey(), 'databaseInternalId' => $database->getSequence(), 'databaseId' => $database->getId(), @@ -868,6 +1012,8 @@ protected function createIndex(Index $resource): bool $createdAt = $this->normalizeDateTime($resource->getCreatedAt()); $updatedAt = $this->normalizeDateTime($resource->getUpdatedAt(), $createdAt); + $this->trackOrphanCandidate($database, $table, 'indexKeys', $resource->getKey(), $dbForDatabases); + $index = new UtopiaDocument([ '$id' => ID::custom($database->getSequence() . '_' . $table->getSequence() . '_' . $resource->getKey()), 'key' => $resource->getKey(), @@ -920,6 +1066,38 @@ protected function createIndex(Index $resource): bool ); } + $indexMetaId = $database->getSequence() . '_' . $table->getSequence() . '_' . $resource->getKey(); + if ($this->onDuplicate !== OnDuplicate::Fail) { + $existingIdx = $this->dbForProject->getDocument('indexes', $indexMetaId); + $action = $this->onDuplicate->resolveSchemaAction( + !$existingIdx->isEmpty(), + $updatedAt, + $existingIdx->getUpdatedAt(), + canDrop: true, + ); + + if ($action === SchemaAction::Tolerate) { + $this->dbForProject->purgeCachedDocument( + 'database_' . $database->getSequence(), + $table->getId() + ); + $resource->setStatus(Resource::STATUS_SKIPPED, 'Already exists on destination'); + return false; + } + + if ($action === SchemaAction::DropAndRecreate) { + $dbForDatabases->deleteIndex( + 'database_' . $database->getSequence() . '_collection_' . $table->getSequence(), + $resource->getKey() + ); + $this->dbForProject->deleteDocument('indexes', $indexMetaId); + $this->dbForProject->purgeCachedDocument( + 'database_' . $database->getSequence(), + $table->getId() + ); + } + } + $index = $this->dbForProject->createDocument('indexes', $index); try { @@ -1043,6 +1221,13 @@ protected function createRecord(Row $resource, bool $isLast): bool $databaseInternalId = $database->getSequence(); $tableInternalId = $table->getSequence(); $dbForDatabases = ($this->getDatabasesDB)($database); + + // Reconcile the destination SCHEMA before rows land: drops + // attributes/indexes source no longer declares so the + // Structure validator doesn't reject rows on orphan required + // columns. Distinct from the payload-shape pass below, which + // strips extra fields off the incoming ROW documents. + $this->cleanupUpsertOrphansForTable($this->tableIdentity($database, $table)); /** * This is in case an attribute was deleted from Appwrite attributes collection but was not deleted from the table * When creating an archive we select * which will include orphan attribute from the schema @@ -1094,6 +1279,306 @@ protected function createRecord(Row $resource, bool $isLast): bool return true; } + /** + * Drop a non-relationship attribute (metadata doc + physical column) so + * it can be recreated. Not used for relationships — those route through + * UpdateInPlace since utopia's deleteAttribute throws for VAR_RELATIONSHIP + * and dropping a relationship column would cascade data loss to the + * partner table's rows. + */ + private function dropAttributeForRecreate( + UtopiaDocument $database, + UtopiaDocument $table, + Column|Attribute $resource, + UtopiaDatabase $dbForDatabases, + ): void { + $collectionId = 'database_' . $database->getSequence() . '_collection_' . $table->getSequence(); + $attributeMetaId = $database->getSequence() . '_' . $table->getSequence() . '_' . $resource->getKey(); + + $dbForDatabases->deleteAttribute($collectionId, $resource->getKey()); + $this->dbForProject->deleteDocument('attributes', $attributeMetaId); + $this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $table->getId()); + $dbForDatabases->purgeCachedCollection($collectionId); + } + + /** + * Reconcile a relationship attribute's metadata on destination without + * dropping its physical column — keeps row data intact. + * + * Two layers must stay in sync: utopia-php/database's internal + * `_metadata` collection (used for row validation) and Appwrite's + * application-level `attributes` doc (used by the Appwrite API). Each + * side's own pass is authoritative for its own Appwrite-level doc; + * utopia's updateRelationship cascades internal metadata on both sides + * in one call and is idempotent across passes for same-target writes. + * + * relationType changes aren't supported by updateRelationship (they'd + * require a physical schema change). Fail fast rather than silently + * diverge utopia's view from Appwrite's view. + */ + private function updateRelationshipInPlace( + UtopiaDocument $database, + UtopiaDocument $table, + Column|Attribute $resource, + string $type, + string $updatedAt, + UtopiaDocument $existingAttr, + UtopiaDatabase $dbForDatabases, + ): void { + $collectionId = 'database_' . $database->getSequence() . '_collection_' . $table->getSequence(); + $sourceOptions = $resource->getOptions(); + $destOptions = $existingAttr->getAttribute('options', []); + + if ( + isset($sourceOptions['relationType'], $destOptions['relationType']) + && $sourceOptions['relationType'] !== $destOptions['relationType'] + ) { + throw new Exception( + resourceName: $resource->getName(), + resourceGroup: $resource->getGroup(), + resourceId: $resource->getId(), + message: 'Changing relationType on a migrated relationship is not supported; drop and recreate the relationship on the destination manually before re-running the migration.', + ); + } + + $dbForDatabases->updateRelationship( + collection: $collectionId, + id: $resource->getKey(), + newTwoWayKey: ($sourceOptions['twoWayKey'] ?? null) !== ($destOptions['twoWayKey'] ?? null) + ? (string) ($sourceOptions['twoWayKey'] ?? '') + : null, + twoWay: ($sourceOptions['twoWay'] ?? null) !== ($destOptions['twoWay'] ?? null) + ? (bool) ($sourceOptions['twoWay'] ?? false) + : null, + onDelete: ($sourceOptions['onDelete'] ?? null) !== ($destOptions['onDelete'] ?? null) + ? (string) ($sourceOptions['onDelete'] ?? '') + : null, + ); + + $this->dbForProject->updateDocument('attributes', $existingAttr->getId(), new UtopiaDocument([ + 'key' => $resource->getKey(), + 'type' => $type, + 'size' => $resource->getSize(), + 'required' => $resource->isRequired(), + 'signed' => $resource->isSigned(), + 'default' => $resource->getDefault(), + 'array' => $resource->isArray(), + 'format' => $resource->getFormat(), + 'formatOptions' => $resource->getFormatOptions(), + 'filters' => $resource->getFilters(), + 'options' => $sourceOptions, + '$updatedAt' => $updatedAt, + ])); + + $this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $table->getId()); + $dbForDatabases->purgeCachedCollection($collectionId); + } + + /** + * Deterministic per-(database, table) key used to index the orphan + * tracker (`$orphansByTable`). Built from sequences so it's stable + * across calls but unique within the run. + */ + private function tableIdentity(UtopiaDocument $database, UtopiaDocument $table): string + { + return $database->getSequence() . ':' . $table->getSequence(); + } + + /** + * Records that $key was seen for this (database, table) during the run. + * $kind selects the tracker bucket ('attributeKeys' or 'indexKeys'). Only + * Upsert mode accumulates; Skip/Fail short-circuit. + */ + private function trackOrphanCandidate( + UtopiaDocument $database, + UtopiaDocument $table, + string $kind, + string $key, + UtopiaDatabase $dbForDatabases, + ): void { + if ($this->onDuplicate !== OnDuplicate::Upsert) { + return; + } + $tableId = $this->tableIdentity($database, $table); + if (!isset($this->orphansByTable[$tableId])) { + $this->orphansByTable[$tableId] = [ + 'database' => $database, + 'table' => $table, + 'dbForDatabases' => $dbForDatabases, + 'attributeKeys' => [], + 'indexKeys' => [], + ]; + } + $this->orphansByTable[$tableId][$kind][] = $key; + } + + /** + * End-of-migration sweep: cleans up any table not already handled by a + * per-table cleanup in createRecord (i.e. tables that had no rows). + * Skipped if the migration failed before run() completed. + */ + private function cleanupUpsertOrphans(): void + { + foreach (\array_keys($this->orphansByTable) as $tableId) { + $this->cleanupUpsertOrphansForTable($tableId); + } + } + + /** + * Drops destination attributes/indexes whose keys weren't observed from + * source for this table. Called per-table from createRecord before rows + * land so the Structure validator sees the post-cleanup schema. After + * running, the entry is removed from the tracker so the end-of-run + * sweep doesn't re-do the work. + */ + private function cleanupUpsertOrphansForTable(string $tableId): void + { + if ($this->onDuplicate !== OnDuplicate::Upsert) { + return; + } + if (!isset($this->orphansByTable[$tableId])) { + return; + } + + $tracked = $this->orphansByTable[$tableId]; + $database = $tracked['database']; + $table = $tracked['table']; + $dbForDatabases = $tracked['dbForDatabases']; + + $this->dropOrphansByKind( + 'attributes', + $tracked['attributeKeys'], + $database, + $table, + fn (UtopiaDocument $doc) => $this->dropOrphanAttribute($database, $table, $doc, $dbForDatabases), + ); + + $this->dropOrphansByKind( + 'indexes', + $tracked['indexKeys'], + $database, + $table, + fn (UtopiaDocument $doc) => $this->dropOrphanIndex( + $database, + $table, + (string) $doc->getAttribute('key'), + $dbForDatabases, + ), + ); + + unset($this->orphansByTable[$tableId]); + } + + /** + * Finds destination metadata docs in $metaCollection belonging to this + * (database, table) and invokes $drop for each one whose key isn't in + * $processedKeys. Shared by the attribute and index cleanup paths. + * + * @param list $processedKeys + * @param callable(UtopiaDocument): void $drop + */ + private function dropOrphansByKind( + string $metaCollection, + array $processedKeys, + UtopiaDocument $database, + UtopiaDocument $table, + callable $drop, + ): void { + $destDocs = $this->dbForProject->find($metaCollection, [ + Query::equal('databaseInternalId', [$database->getSequence()]), + Query::equal('collectionInternalId', [$table->getSequence()]), + Query::limit(PHP_INT_MAX), + ]); + foreach ($destDocs as $destDoc) { + if (!\in_array($destDoc->getAttribute('key'), $processedKeys, true)) { + $drop($destDoc); + } + } + } + + /** + * Drop an orphan attribute (metadata doc + physical column). Uses the + * destination's own metadata document as the source of truth for type + * and relationship options, since there's no source resource to consult. + * + * Relationships use deleteRelationship (which cascades to both sides's + * physical columns + utopia's internal metadata) rather than + * deleteAttribute (which throws for relationship types). Appwrite's + * application-level `attributes` metadata docs on both sides are + * cleaned separately. + */ + private function dropOrphanAttribute( + UtopiaDocument $database, + UtopiaDocument $table, + UtopiaDocument $attrDoc, + UtopiaDatabase $dbForDatabases, + ): void { + $key = (string) $attrDoc->getAttribute('key'); + $type = (string) $attrDoc->getAttribute('type'); + $options = $attrDoc->getAttribute('options', []); + $collectionId = 'database_' . $database->getSequence() . '_collection_' . $table->getSequence(); + + if ($type === UtopiaDatabase::VAR_RELATIONSHIP) { + $this->tolerateMissing(fn () => $dbForDatabases->deleteRelationship($collectionId, $key)); + } else { + $this->tolerateMissing(fn () => $dbForDatabases->deleteAttribute($collectionId, $key)); + } + $this->tolerateMissing(fn () => $this->dbForProject->deleteDocument('attributes', $attrDoc->getId())); + $this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $table->getId()); + $dbForDatabases->purgeCachedCollection($collectionId); + + if ($type !== UtopiaDatabase::VAR_RELATIONSHIP || empty($options['twoWay'])) { + return; + } + $twoWayKey = (string) ($options['twoWayKey'] ?? ''); + $relatedTableId = (string) ($options['relatedCollection'] ?? ''); + if ($twoWayKey === '' || $relatedTableId === '') { + return; + } + $relatedTable = $this->dbForProject->getDocument('database_' . $database->getSequence(), $relatedTableId); + if ($relatedTable->isEmpty()) { + return; + } + + // Physical column on the related table was already dropped by + // deleteRelationship above. Only the Appwrite-level metadata doc + // remains to clean up. + $childMetaId = $database->getSequence() . '_' . $relatedTable->getSequence() . '_' . $twoWayKey; + $this->tolerateMissing(fn () => $this->dbForProject->deleteDocument('attributes', $childMetaId)); + $this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $relatedTable->getId()); + $dbForDatabases->purgeCachedCollection( + 'database_' . $database->getSequence() . '_collection_' . $relatedTable->getSequence(), + ); + } + + private function dropOrphanIndex( + UtopiaDocument $database, + UtopiaDocument $table, + string $indexKey, + UtopiaDatabase $dbForDatabases, + ): void { + $collectionId = 'database_' . $database->getSequence() . '_collection_' . $table->getSequence(); + $indexMetaId = $database->getSequence() . '_' . $table->getSequence() . '_' . $indexKey; + + $this->tolerateMissing(fn () => $dbForDatabases->deleteIndex($collectionId, $indexKey)); + $this->tolerateMissing(fn () => $this->dbForProject->deleteDocument('indexes', $indexMetaId)); + $this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $table->getId()); + } + + /** + * Swallows deletion errors — a prior interrupted run (or parent-side + * cascade via utopia-php/database relationship handling) may already + * have removed the target. + */ + private function tolerateMissing(callable $fn): void + { + try { + $fn(); + } catch (\Throwable) { + // already gone + } + } + /** * @throws \Exception */ diff --git a/src/Migration/Destinations/OnDuplicate.php b/src/Migration/Destinations/OnDuplicate.php index 31a0e49f..a40b50e1 100644 --- a/src/Migration/Destinations/OnDuplicate.php +++ b/src/Migration/Destinations/OnDuplicate.php @@ -3,8 +3,19 @@ namespace Utopia\Migration\Destinations; /** - * Behavior when a destination row with an existing ID is encountered. + * Outcome of {@see OnDuplicate::resolveSchemaAction()}. Declared alongside + * OnDuplicate because the two are designed together — any code that uses + * SchemaAction necessarily imports OnDuplicate (as the source of the + * decision), so the shared file satisfies autoloading in practice. */ +enum SchemaAction +{ + case Create; + case Tolerate; + case DropAndRecreate; + case UpdateInPlace; +} + enum OnDuplicate: string { case Fail = 'fail'; @@ -12,10 +23,53 @@ enum OnDuplicate: string case Upsert = 'upsert'; /** - * @return array + * @return list */ public static function values(): array { - return \array_map(fn (self $case) => $case->value, self::cases()); + return \array_values(\array_map(fn (self $case) => $case->value, self::cases())); + } + + /** + * Schema-level reconciliation decision. + * + * $canDrop = true → leaves (attributes, indexes) get DropAndRecreate on Upsert-newer. + * $canDrop = false → containers (databases, tables) get UpdateInPlace on Upsert-newer. + * Default is false so destructive reconciliation requires explicit opt-in. + */ + public function resolveSchemaAction( + bool $exists, + ?string $sourceUpdatedAt = null, + ?string $destUpdatedAt = null, + bool $canDrop = false, + ): SchemaAction { + if (!$exists) { + return SchemaAction::Create; + } + return match ($this) { + self::Fail => SchemaAction::Create, + self::Skip => SchemaAction::Tolerate, + self::Upsert => $this->sourceIsNewer($sourceUpdatedAt, $destUpdatedAt) + ? ($canDrop ? SchemaAction::DropAndRecreate : SchemaAction::UpdateInPlace) + : SchemaAction::Tolerate, + }; + } + + /** + * strtotime() accepts '0000-00-00' leniently (returns a large negative + * epoch, not false), so reject non-positive epochs too. Null/empty are + * treated as unknown → tolerate rather than risk a destructive drop. + */ + private function sourceIsNewer(?string $source, ?string $dest): bool + { + if ($source === null || $source === '' || $dest === null || $dest === '') { + return false; + } + $src = \strtotime($source); + $dst = \strtotime($dest); + if ($src === false || $dst === false || $src <= 0 || $dst <= 0) { + return false; + } + return $src > $dst; } } diff --git a/tests/Migration/Unit/General/OnDuplicateTest.php b/tests/Migration/Unit/General/OnDuplicateTest.php new file mode 100644 index 00000000..42ffa296 --- /dev/null +++ b/tests/Migration/Unit/General/OnDuplicateTest.php @@ -0,0 +1,176 @@ +assertSame( + SchemaAction::Create, + $mode->resolveSchemaAction(exists: false), + "{$mode->value} on non-existing resource must return Create", + ); + } + } + + public function testFailExistsReturnsCreateSoCallerDDLThrows(): void + { + // Fail is routed through Create (not Tolerate) so the caller's normal + // create flow runs and the library surfaces DuplicateException as + // designed. Returning Tolerate here would silently hide the error. + $this->assertSame( + SchemaAction::Create, + OnDuplicate::Fail->resolveSchemaAction(exists: true), + ); + } + + public function testSkipAlwaysToleratesExisting(): void + { + // Skip must ignore timestamps entirely — it's the "don't touch" + // contract. Exercise both orderings to prove the comparison isn't + // consulted. + $this->assertSame( + SchemaAction::Tolerate, + OnDuplicate::Skip->resolveSchemaAction( + exists: true, + sourceUpdatedAt: '2026-01-01T00:00:00.000+00:00', + destUpdatedAt: '2020-01-01T00:00:00.000+00:00', + ), + ); + $this->assertSame( + SchemaAction::Tolerate, + OnDuplicate::Skip->resolveSchemaAction( + exists: true, + sourceUpdatedAt: '2020-01-01T00:00:00.000+00:00', + destUpdatedAt: '2026-01-01T00:00:00.000+00:00', + ), + ); + } + + public function testUpsertLeafSourceNewerDropsAndRecreates(): void + { + // canDrop: true → leaf resource (attribute, index). Column data is + // repopulated by the follow-up row Upsert, or the resource is pure + // metadata (index) so destructive reconciliation is safe. + $this->assertSame( + SchemaAction::DropAndRecreate, + OnDuplicate::Upsert->resolveSchemaAction( + exists: true, + sourceUpdatedAt: '2026-04-23T10:00:00.000+00:00', + destUpdatedAt: '2026-04-23T09:59:59.000+00:00', + canDrop: true, + ), + ); + } + + public function testUpsertContainerSourceNewerUpdatesInPlace(): void + { + // Default canDrop: false → container resource (database, table). + // Container's metadata doc gets updateDocument; children (tables, + // rows) are preserved untouched. + $this->assertSame( + SchemaAction::UpdateInPlace, + OnDuplicate::Upsert->resolveSchemaAction( + exists: true, + sourceUpdatedAt: '2026-04-23T10:00:00.000+00:00', + destUpdatedAt: '2026-04-23T09:59:59.000+00:00', + ), + ); + } + + public function testSkipNeverUpdatesContainerEvenWhenSourceNewer(): void + { + // Skip is strict "don't touch" — must never return UpdateInPlace, + // only Tolerate, regardless of timestamps or resource kind. + $this->assertSame( + SchemaAction::Tolerate, + OnDuplicate::Skip->resolveSchemaAction( + exists: true, + sourceUpdatedAt: '2026-04-23T10:00:00.000+00:00', + destUpdatedAt: '2026-04-23T09:59:59.000+00:00', + ), + ); + } + + public function testUpsertDestNewerTolerates(): void + { + $this->assertSame( + SchemaAction::Tolerate, + OnDuplicate::Upsert->resolveSchemaAction( + exists: true, + sourceUpdatedAt: '2026-04-23T09:00:00.000+00:00', + destUpdatedAt: '2026-04-23T10:00:00.000+00:00', + ), + ); + } + + public function testUpsertEqualTimestampsTolerates(): void + { + // Strict > comparison: equal means dest is already in sync, skip the + // drop. Avoids unnecessary destructive rebuild when the user hasn't + // touched source since the last migration. + $stamp = '2026-04-23T10:00:00.000+00:00'; + $this->assertSame( + SchemaAction::Tolerate, + OnDuplicate::Upsert->resolveSchemaAction( + exists: true, + sourceUpdatedAt: $stamp, + destUpdatedAt: $stamp, + ), + ); + } + + /** + * @return array + */ + public static function unparseableTimestampPairs(): array + { + return [ + 'both empty' => ['', ''], + 'both null' => [null, null], + 'source empty' => ['', '2026-04-23T10:00:00.000+00:00'], + 'dest empty' => ['2026-04-23T10:00:00.000+00:00', ''], + 'source null' => [null, '2026-04-23T10:00:00.000+00:00'], + 'dest null' => ['2026-04-23T10:00:00.000+00:00', null], + 'source zero-date' => ['0000-00-00 00:00:00', '2026-04-23T10:00:00.000+00:00'], + 'dest zero-date' => ['2026-04-23T10:00:00.000+00:00', '0000-00-00 00:00:00'], + 'source garbage' => ['not-a-date', '2026-04-23T10:00:00.000+00:00'], + 'dest garbage' => ['2026-04-23T10:00:00.000+00:00', 'not-a-date'], + ]; + } + + #[DataProvider('unparseableTimestampPairs')] + public function testUpsertUnparseableTimestampsTolerate(?string $source, ?string $dest): void + { + // Conservative: unparseable timestamps preserve existing destination + // rather than risk a destructive drop on garbage input. Any + // non-Appwrite source that emits malformed dates gets handled safely. + $this->assertSame( + SchemaAction::Tolerate, + OnDuplicate::Upsert->resolveSchemaAction( + exists: true, + sourceUpdatedAt: $source, + destUpdatedAt: $dest, + ), + ); + } + + public function testValuesListsAllCasesInDeclarationOrder(): void + { + // The values() helper is consumed by API/SDK param validators; this + // tests protects against an accidental case-rename or reorder. + $this->assertSame(['fail', 'skip', 'upsert'], OnDuplicate::values()); + } +}