@@ -104,18 +104,6 @@ class Appwrite extends Destination
104104 */
105105 private array $ orphansByTable = [];
106106
107- /**
108- * Set of two-way relationship pair identities already reconciled during
109- * this run. Keyed by a canonical pair-key independent of which side is
110- * being processed. Used by createField to short-circuit the partner's
111- * second pass — the first side's action already reconciled both sides
112- * (Tolerate no-ops, DropAndRecreate drops + recreates both via
113- * dropTwoWayMirror + createRelationship).
114- *
115- * @var array<string, true>
116- */
117- private array $ processedTwoWayPairs = [];
118-
119107 /**
120108 * @param string $project
121109 * @param string $endpoint
@@ -757,19 +745,11 @@ protected function createField(Column|Attribute $resource): bool
757745
758746 $ this ->trackOrphanCandidate ($ database , $ table , 'attributeKeys ' , $ resource ->getKey (), $ dbForDatabases );
759747
760- // Two-way relationships have mirrored metadata + columns on both
761- // tables. Processing one side already reconciles both (Tolerate
762- // no-ops, DropAndRecreate drops + recreates both via
763- // dropTwoWayMirror + createRelationship). When the partner side
764- // comes through, skip it — re-processing would either double-drop
765- // (destroying row data on tables whose rows already migrated) or
766- // waste work. The first side always wins; subsequent partner is
767- // idempotent from the orchestrator's perspective.
768- $ twoWayPairKey = $ this ->twoWayPairKey ($ database , $ table , $ resource , $ type );
769- if ($ twoWayPairKey !== null && isset ($ this ->processedTwoWayPairs [$ twoWayPairKey ])) {
770- $ resource ->setStatus (Resource::STATUS_SKIPPED , 'Two-way partner already reconciled ' );
771- return false ;
772- }
748+ // Relationships route to UpdateInPlace, not DropAndRecreate:
749+ // utopia's deleteAttribute throws for VAR_RELATIONSHIP, and
750+ // two-way drops would cascade to the partner table's physical
751+ // column. Non-relationship attrs keep DropAndRecreate.
752+ $ isRelationship = $ type === UtopiaDatabase::VAR_RELATIONSHIP ;
773753
774754 $ attributeMetaId = $ database ->getSequence () . '_ ' . $ table ->getSequence () . '_ ' . $ resource ->getKey ();
775755 if ($ this ->onDuplicate !== OnDuplicate::Fail) {
@@ -778,28 +758,31 @@ protected function createField(Column|Attribute $resource): bool
778758 !$ existingAttr ->isEmpty (),
779759 $ updatedAt ,
780760 $ existingAttr ->getUpdatedAt (),
781- canDrop: true ,
761+ canDrop: ! $ isRelationship ,
782762 );
783763
784764 if ($ action === SchemaAction::Tolerate) {
785765 $ this ->dbForProject ->purgeCachedDocument ('database_ ' . $ database ->getSequence (), $ table ->getId ());
786766 $ dbForDatabases ->purgeCachedCollection ('database_ ' . $ database ->getSequence () . '_collection_ ' . $ table ->getSequence ());
787767 $ resource ->setStatus (Resource::STATUS_SKIPPED , 'Already exists on destination ' );
788- if ($ twoWayPairKey !== null ) {
789- $ this ->processedTwoWayPairs [$ twoWayPairKey ] = true ;
790- }
791768 return false ;
792769 }
793770
794- if ($ action === SchemaAction::DropAndRecreate ) {
795- $ this ->dropAttributeForRecreate (
771+ if ($ action === SchemaAction::UpdateInPlace ) {
772+ $ this ->updateRelationshipInPlace (
796773 $ database ,
797774 $ table ,
798775 $ resource ,
799776 $ type ,
800- $ relatedTable ?? null ,
777+ $ updatedAt ,
778+ $ existingAttr ,
801779 $ dbForDatabases ,
802780 );
781+ return true ;
782+ }
783+
784+ if ($ action === SchemaAction::DropAndRecreate) {
785+ $ this ->dropAttributeForRecreate ($ database , $ table , $ resource , $ dbForDatabases );
803786 }
804787 }
805788
@@ -969,10 +952,6 @@ protected function createField(Column|Attribute $resource): bool
969952 $ this ->dbForProject ->purgeCachedDocument ('database_ ' . $ database ->getSequence (), $ table ->getId ());
970953 $ dbForDatabases ->purgeCachedCollection ('database_ ' . $ database ->getSequence () . '_collection_ ' . $ table ->getSequence ());
971954
972- if ($ twoWayPairKey !== null ) {
973- $ this ->processedTwoWayPairs [$ twoWayPairKey ] = true ;
974- }
975-
976955 return true ;
977956 }
978957
@@ -1301,18 +1280,16 @@ protected function createRecord(Row $resource, bool $isLast): bool
13011280 }
13021281
13031282 /**
1304- * Drop an attribute (metadata doc + physical column) so it can be recreated.
1305- *
1306- * Two-way relationships require mirroring: createField writes a second
1307- * `attributes` document on the related table keyed by twoWayKey. Dropping
1308- * only the parent leaves that child doc dangling and the recreate collides .
1283+ * Drop a non-relationship attribute (metadata doc + physical column) so
1284+ * it can be recreated. Not used for relationships — those route through
1285+ * UpdateInPlace since utopia's deleteAttribute throws for VAR_RELATIONSHIP
1286+ * and dropping a relationship column would cascade data loss to the
1287+ * partner table's rows .
13091288 */
13101289 private function dropAttributeForRecreate (
13111290 UtopiaDocument $ database ,
13121291 UtopiaDocument $ table ,
13131292 Column |Attribute $ resource ,
1314- string $ type ,
1315- ?UtopiaDocument $ relatedTable ,
13161293 UtopiaDatabase $ dbForDatabases ,
13171294 ): void {
13181295 $ collectionId = 'database_ ' . $ database ->getSequence () . '_collection_ ' . $ table ->getSequence ();
@@ -1322,40 +1299,79 @@ private function dropAttributeForRecreate(
13221299 $ this ->dbForProject ->deleteDocument ('attributes ' , $ attributeMetaId );
13231300 $ this ->dbForProject ->purgeCachedDocument ('database_ ' . $ database ->getSequence (), $ table ->getId ());
13241301 $ dbForDatabases ->purgeCachedCollection ($ collectionId );
1325-
1326- if ($ type !== UtopiaDatabase::VAR_RELATIONSHIP || $ relatedTable === null ) {
1327- return ;
1328- }
1329- $ options = $ resource ->getOptions ();
1330- if (empty ($ options ['twoWay ' ])) {
1331- return ;
1332- }
1333- $ twoWayKey = (string ) ($ options ['twoWayKey ' ] ?? '' );
1334- if ($ twoWayKey === '' ) {
1335- return ;
1336- }
1337-
1338- $ this ->dropTwoWayMirror ($ database , $ relatedTable , $ twoWayKey , $ dbForDatabases );
13391302 }
13401303
13411304 /**
1342- * Drop the child-side `attributes` metadata doc + physical column that
1343- * createField writes for two-way relationships. Shared by the DropAndRecreate
1344- * path (pre-fetched $relatedTable) and the orphan cleanup path.
1305+ * Reconcile a relationship attribute's metadata on destination without
1306+ * dropping its physical column — keeps row data intact.
1307+ *
1308+ * Two layers must stay in sync: utopia-php/database's internal
1309+ * `_metadata` collection (used for row validation) and Appwrite's
1310+ * application-level `attributes` doc (used by the Appwrite API). Each
1311+ * side's own pass is authoritative for its own Appwrite-level doc;
1312+ * utopia's updateRelationship cascades internal metadata on both sides
1313+ * in one call and is idempotent across passes for same-target writes.
1314+ *
1315+ * relationType changes aren't supported by updateRelationship (they'd
1316+ * require a physical schema change). Fail fast rather than silently
1317+ * diverge utopia's view from Appwrite's view.
13451318 */
1346- private function dropTwoWayMirror (
1319+ private function updateRelationshipInPlace (
13471320 UtopiaDocument $ database ,
1348- UtopiaDocument $ relatedTable ,
1349- string $ twoWayKey ,
1321+ UtopiaDocument $ table ,
1322+ Column |Attribute $ resource ,
1323+ string $ type ,
1324+ string $ updatedAt ,
1325+ UtopiaDocument $ existingAttr ,
13501326 UtopiaDatabase $ dbForDatabases ,
13511327 ): void {
1352- $ childCollectionId = 'database_ ' . $ database ->getSequence () . '_collection_ ' . $ relatedTable ->getSequence ();
1353- $ childMetaId = $ database ->getSequence () . '_ ' . $ relatedTable ->getSequence () . '_ ' . $ twoWayKey ;
1328+ $ collectionId = 'database_ ' . $ database ->getSequence () . '_collection_ ' . $ table ->getSequence ();
1329+ $ sourceOptions = $ resource ->getOptions ();
1330+ $ destOptions = $ existingAttr ->getAttribute ('options ' , []);
13541331
1355- $ this ->tolerateMissing (fn () => $ this ->dbForProject ->deleteDocument ('attributes ' , $ childMetaId ));
1356- $ this ->tolerateMissing (fn () => $ dbForDatabases ->deleteAttribute ($ childCollectionId , $ twoWayKey ));
1357- $ this ->dbForProject ->purgeCachedDocument ('database_ ' . $ database ->getSequence (), $ relatedTable ->getId ());
1358- $ dbForDatabases ->purgeCachedCollection ($ childCollectionId );
1332+ if (
1333+ isset ($ sourceOptions ['relationType ' ], $ destOptions ['relationType ' ])
1334+ && $ sourceOptions ['relationType ' ] !== $ destOptions ['relationType ' ]
1335+ ) {
1336+ throw new Exception (
1337+ resourceName: $ resource ->getName (),
1338+ resourceGroup: $ resource ->getGroup (),
1339+ resourceId: $ resource ->getId (),
1340+ message: 'Changing relationType on a migrated relationship is not supported; drop and recreate the relationship on the destination manually before re-running the migration. ' ,
1341+ );
1342+ }
1343+
1344+ $ dbForDatabases ->updateRelationship (
1345+ collection: $ collectionId ,
1346+ id: $ resource ->getKey (),
1347+ newTwoWayKey: ($ sourceOptions ['twoWayKey ' ] ?? null ) !== ($ destOptions ['twoWayKey ' ] ?? null )
1348+ ? (string ) ($ sourceOptions ['twoWayKey ' ] ?? '' )
1349+ : null ,
1350+ twoWay: ($ sourceOptions ['twoWay ' ] ?? null ) !== ($ destOptions ['twoWay ' ] ?? null )
1351+ ? (bool ) ($ sourceOptions ['twoWay ' ] ?? false )
1352+ : null ,
1353+ onDelete: ($ sourceOptions ['onDelete ' ] ?? null ) !== ($ destOptions ['onDelete ' ] ?? null )
1354+ ? (string ) ($ sourceOptions ['onDelete ' ] ?? '' )
1355+ : null ,
1356+ );
1357+
1358+ $ this ->dbForProject ->updateDocument ('attributes ' , $ existingAttr ->getId (), new UtopiaDocument ([
1359+ 'key ' => $ resource ->getKey (),
1360+ 'type ' => $ type ,
1361+ 'size ' => $ resource ->getSize (),
1362+ 'required ' => $ resource ->isRequired (),
1363+ 'signed ' => $ resource ->isSigned (),
1364+ 'default ' => $ resource ->getDefault (),
1365+ 'array ' => $ resource ->isArray (),
1366+ 'format ' => $ resource ->getFormat (),
1367+ 'formatOptions ' => $ resource ->getFormatOptions (),
1368+ 'filters ' => $ resource ->getFilters (),
1369+ 'options ' => $ sourceOptions ,
1370+ '$updatedAt ' => $ updatedAt ,
1371+ ]));
1372+
1373+ $ this ->dbForProject ->purgeCachedDocument ('database_ ' . $ database ->getSequence (), $ table ->getId ());
1374+ $ dbForDatabases ->purgeCachedCollection ($ collectionId );
13591375 }
13601376
13611377 /**
@@ -1368,38 +1384,6 @@ private function tableIdentity(UtopiaDocument $database, UtopiaDocument $table):
13681384 return $ database ->getSequence () . ': ' . $ table ->getSequence ();
13691385 }
13701386
1371- /**
1372- * Canonical identity for a two-way relationship pair — same string
1373- * regardless of which side (parent or child) is being processed.
1374- * Returns null when the resource isn't a two-way relationship (no
1375- * pair-level tracking needed).
1376- */
1377- private function twoWayPairKey (
1378- UtopiaDocument $ database ,
1379- UtopiaDocument $ table ,
1380- Column |Attribute $ resource ,
1381- string $ type ,
1382- ): ?string {
1383- if ($ type !== UtopiaDatabase::VAR_RELATIONSHIP ) {
1384- return null ;
1385- }
1386- $ options = $ resource ->getOptions ();
1387- if (empty ($ options ['twoWay ' ])) {
1388- return null ;
1389- }
1390- $ twoWayKey = (string ) ($ options ['twoWayKey ' ] ?? '' );
1391- $ relatedTableId = (string ) ($ options ['relatedCollection ' ] ?? '' );
1392- if ($ twoWayKey === '' || $ relatedTableId === '' ) {
1393- return null ;
1394- }
1395-
1396- $ thisSide = $ table ->getId () . ':: ' . $ resource ->getKey ();
1397- $ partnerSide = $ relatedTableId . ':: ' . $ twoWayKey ;
1398- $ pair = [$ thisSide , $ partnerSide ];
1399- \sort ($ pair );
1400- return $ database ->getSequence () . '@ ' . \implode ('<-> ' , $ pair );
1401- }
1402-
14031387 /**
14041388 * Records that $key was seen for this (database, table) during the run.
14051389 * $kind selects the tracker bucket ('attributeKeys' or 'indexKeys'). Only
@@ -1516,6 +1500,12 @@ private function dropOrphansByKind(
15161500 * Drop an orphan attribute (metadata doc + physical column). Uses the
15171501 * destination's own metadata document as the source of truth for type
15181502 * and relationship options, since there's no source resource to consult.
1503+ *
1504+ * Relationships use deleteRelationship (which cascades to both sides's
1505+ * physical columns + utopia's internal metadata) rather than
1506+ * deleteAttribute (which throws for relationship types). Appwrite's
1507+ * application-level `attributes` metadata docs on both sides are
1508+ * cleaned separately.
15191509 */
15201510 private function dropOrphanAttribute (
15211511 UtopiaDocument $ database ,
@@ -1528,7 +1518,11 @@ private function dropOrphanAttribute(
15281518 $ options = $ attrDoc ->getAttribute ('options ' , []);
15291519 $ collectionId = 'database_ ' . $ database ->getSequence () . '_collection_ ' . $ table ->getSequence ();
15301520
1531- $ this ->tolerateMissing (fn () => $ dbForDatabases ->deleteAttribute ($ collectionId , $ key ));
1521+ if ($ type === UtopiaDatabase::VAR_RELATIONSHIP ) {
1522+ $ this ->tolerateMissing (fn () => $ dbForDatabases ->deleteRelationship ($ collectionId , $ key ));
1523+ } else {
1524+ $ this ->tolerateMissing (fn () => $ dbForDatabases ->deleteAttribute ($ collectionId , $ key ));
1525+ }
15321526 $ this ->tolerateMissing (fn () => $ this ->dbForProject ->deleteDocument ('attributes ' , $ attrDoc ->getId ()));
15331527 $ this ->dbForProject ->purgeCachedDocument ('database_ ' . $ database ->getSequence (), $ table ->getId ());
15341528 $ dbForDatabases ->purgeCachedCollection ($ collectionId );
@@ -1546,12 +1540,15 @@ private function dropOrphanAttribute(
15461540 return ;
15471541 }
15481542
1549- $ childCollectionId = 'database_ ' . $ database ->getSequence () . '_collection_ ' . $ relatedTable ->getSequence ();
1543+ // Physical column on the related table was already dropped by
1544+ // deleteRelationship above. Only the Appwrite-level metadata doc
1545+ // remains to clean up.
15501546 $ childMetaId = $ database ->getSequence () . '_ ' . $ relatedTable ->getSequence () . '_ ' . $ twoWayKey ;
15511547 $ this ->tolerateMissing (fn () => $ this ->dbForProject ->deleteDocument ('attributes ' , $ childMetaId ));
1552- $ this ->tolerateMissing (fn () => $ dbForDatabases ->deleteAttribute ($ childCollectionId , $ twoWayKey ));
15531548 $ this ->dbForProject ->purgeCachedDocument ('database_ ' . $ database ->getSequence (), $ relatedTable ->getId ());
1554- $ dbForDatabases ->purgeCachedCollection ($ childCollectionId );
1549+ $ dbForDatabases ->purgeCachedCollection (
1550+ 'database_ ' . $ database ->getSequence () . '_collection_ ' . $ relatedTable ->getSequence (),
1551+ );
15551552 }
15561553
15571554 private function dropOrphanIndex (
0 commit comments