mirror of
				https://github.com/strapi/strapi.git
				synced 2025-11-04 03:43:34 +00:00 
			
		
		
		
	fix: database is corrupt with orphaned relations (#24316)
* fix: database is corrupt with orphaned relations * chore: move script to document service and refactor * fix: refactoring cleanup script * fix: some improvements * test(api): add unidirectional join table repair tests * test(api): remove debug logs * fix: orphaned relation nested compo (#24387) --------- Co-authored-by: Bassel Kanso <basselkanso82@gmail.com> Co-authored-by: Ben Irvin <ben@innerdvations.com> Co-authored-by: Ben Irvin <ben.irvin@strapi.io>
This commit is contained in:
		
							parent
							
								
									aac81a07a7
								
							
						
					
					
						commit
						536eed0201
					
				@ -37,6 +37,8 @@ import { createContentSourceMapsService } from './services/content-source-maps';
 | 
			
		||||
import { coreStoreModel } from './services/core-store';
 | 
			
		||||
import { createConfigProvider } from './services/config';
 | 
			
		||||
 | 
			
		||||
import { cleanComponentJoinTable } from './services/document-service/utils/clean-component-join-table';
 | 
			
		||||
 | 
			
		||||
class Strapi extends Container implements Core.Strapi {
 | 
			
		||||
  app: any;
 | 
			
		||||
 | 
			
		||||
@ -450,6 +452,20 @@ class Strapi extends Container implements Core.Strapi {
 | 
			
		||||
      await this.db.repair.removeOrphanMorphType({ pivot: 'component_type' });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const alreadyRanComponentRepair = await this.store.get({
 | 
			
		||||
      type: 'strapi',
 | 
			
		||||
      key: 'unidirectional-join-table-repair-ran',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!alreadyRanComponentRepair) {
 | 
			
		||||
      await this.db.repair.processUnidirectionalJoinTables(cleanComponentJoinTable);
 | 
			
		||||
      await this.store.set({
 | 
			
		||||
        type: 'strapi',
 | 
			
		||||
        key: 'unidirectional-join-table-repair-ran',
 | 
			
		||||
        value: true,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this.EE) {
 | 
			
		||||
      await utils.ee.checkLicense({ strapi: this });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -455,7 +455,7 @@ const assignComponentData = curry(
 | 
			
		||||
const findComponentParent = async (
 | 
			
		||||
  componentSchema: Schema.Component,
 | 
			
		||||
  componentId: number | string,
 | 
			
		||||
  parentSchemasForComponent: Schema.ContentType[],
 | 
			
		||||
  parentSchemasForComponent: (Schema.ContentType | Schema.Component)[],
 | 
			
		||||
  opts?: { trx?: any }
 | 
			
		||||
): Promise<{ uid: string; table: string; parentId: number | string } | null> => {
 | 
			
		||||
  if (!componentSchema?.uid) return null;
 | 
			
		||||
@ -504,17 +504,21 @@ const findComponentParent = async (
 | 
			
		||||
/**
 | 
			
		||||
 * Finds content types that contain the given component and have draft & publish enabled.
 | 
			
		||||
 */
 | 
			
		||||
const getParentSchemasForComponent = (componentSchema: Schema.Component): Schema.ContentType[] => {
 | 
			
		||||
  return Object.values(strapi.contentTypes).filter((contentType: any) => {
 | 
			
		||||
    if (!contentType.options?.draftAndPublish) return false;
 | 
			
		||||
 | 
			
		||||
    return Object.values(contentType.attributes).some((attr: any) => {
 | 
			
		||||
const getParentSchemasForComponent = (
 | 
			
		||||
  componentSchema: Schema.Component
 | 
			
		||||
): Array<Schema.ContentType | Schema.Component> => {
 | 
			
		||||
  // Find direct parents in contentTypes and components
 | 
			
		||||
  return [...Object.values(strapi.contentTypes), ...Object.values(strapi.components)].filter(
 | 
			
		||||
    (schema: any) => {
 | 
			
		||||
      if (!schema?.attributes) return false;
 | 
			
		||||
      return Object.values(schema.attributes).some((attr: any) => {
 | 
			
		||||
        return (
 | 
			
		||||
          (attr.type === 'component' && attr.component === componentSchema.uid) ||
 | 
			
		||||
          (attr.type === 'dynamiczone' && attr.components?.includes(componentSchema.uid))
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
  });
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@ -524,7 +528,7 @@ const getParentSchemasForComponent = (componentSchema: Schema.Component): Schema
 | 
			
		||||
const shouldPropagateComponentRelationToNewVersion = async (
 | 
			
		||||
  componentRelation: Record<string, any>,
 | 
			
		||||
  componentSchema: Schema.Component,
 | 
			
		||||
  parentSchemasForComponent: Schema.ContentType[],
 | 
			
		||||
  parentSchemasForComponent: (Schema.ContentType | Schema.Component)[],
 | 
			
		||||
  trx: any
 | 
			
		||||
): Promise<boolean> => {
 | 
			
		||||
  // Get the component ID column name using the actual component model name
 | 
			
		||||
@ -532,7 +536,7 @@ const shouldPropagateComponentRelationToNewVersion = async (
 | 
			
		||||
    _.snakeCase(componentSchema.modelName)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const componentId = componentRelation[componentIdColumn];
 | 
			
		||||
  const componentId = componentRelation[componentIdColumn] ?? componentRelation.parentId;
 | 
			
		||||
 | 
			
		||||
  const parent = await findComponentParent(
 | 
			
		||||
    componentSchema,
 | 
			
		||||
@ -546,6 +550,18 @@ const shouldPropagateComponentRelationToNewVersion = async (
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (strapi.components[parent.uid as UID.Component]) {
 | 
			
		||||
    // If the parent is a component, we need to check its parents recursively
 | 
			
		||||
    const parentComponentSchema = strapi.components[parent.uid as UID.Component];
 | 
			
		||||
    const grandParentSchemas = getParentSchemasForComponent(parentComponentSchema);
 | 
			
		||||
    return shouldPropagateComponentRelationToNewVersion(
 | 
			
		||||
      parent,
 | 
			
		||||
      parentComponentSchema,
 | 
			
		||||
      grandParentSchemas,
 | 
			
		||||
      trx
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const parentContentType = strapi.contentTypes[parent.uid as UID.ContentType];
 | 
			
		||||
 | 
			
		||||
  // Keep relation if parent doesn't have draft & publish enabled
 | 
			
		||||
@ -598,4 +614,6 @@ export {
 | 
			
		||||
  deleteComponents,
 | 
			
		||||
  deleteComponent,
 | 
			
		||||
  createComponentRelationFilter,
 | 
			
		||||
  findComponentParent,
 | 
			
		||||
  getParentSchemasForComponent,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,217 @@
 | 
			
		||||
import type { Database } from '@strapi/database';
 | 
			
		||||
import type { Schema } from '@strapi/types';
 | 
			
		||||
import { findComponentParent, getParentSchemasForComponent } from '../components';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Cleans ghost relations with publication state mismatches from a join table
 | 
			
		||||
 * Uses schema-based draft/publish checking like prevention fix
 | 
			
		||||
 */
 | 
			
		||||
export const cleanComponentJoinTable = async (
 | 
			
		||||
  db: Database,
 | 
			
		||||
  joinTableName: string,
 | 
			
		||||
  relation: any,
 | 
			
		||||
  sourceModel: any
 | 
			
		||||
): Promise<number> => {
 | 
			
		||||
  try {
 | 
			
		||||
    // Get the target model metadata
 | 
			
		||||
    const targetModel = db.metadata.get(relation.target);
 | 
			
		||||
    if (!targetModel) {
 | 
			
		||||
      db.logger.debug(`Target model ${relation.target} not found, skipping ${joinTableName}`);
 | 
			
		||||
      return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check if target supports draft/publish using schema-based approach (like prevention fix)
 | 
			
		||||
    const targetContentType =
 | 
			
		||||
      strapi.contentTypes[relation.target as keyof typeof strapi.contentTypes];
 | 
			
		||||
    const targetSupportsDraftPublish = targetContentType?.options?.draftAndPublish || false;
 | 
			
		||||
 | 
			
		||||
    if (!targetSupportsDraftPublish) {
 | 
			
		||||
      return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Find entries with publication state mismatches
 | 
			
		||||
    const ghostEntries = await findPublicationStateMismatches(
 | 
			
		||||
      db,
 | 
			
		||||
      joinTableName,
 | 
			
		||||
      relation,
 | 
			
		||||
      targetModel,
 | 
			
		||||
      sourceModel
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (ghostEntries.length === 0) {
 | 
			
		||||
      return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Remove ghost entries
 | 
			
		||||
    await db.connection(joinTableName).whereIn('id', ghostEntries).del();
 | 
			
		||||
    db.logger.debug(
 | 
			
		||||
      `Removed ${ghostEntries.length} ghost relations with publication state mismatches from ${joinTableName}`
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return ghostEntries.length;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    const errorMessage = error instanceof Error ? error.message : String(error);
 | 
			
		||||
    db.logger.error(`Failed to clean join table "${joinTableName}": ${errorMessage}`);
 | 
			
		||||
    return 0;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const findContentTypeParentForComponentInstance = async (
 | 
			
		||||
  componentSchema: Schema.Component,
 | 
			
		||||
  componentId: number | string
 | 
			
		||||
): Promise<{ uid: string; table: string; parentId: number | string } | null> => {
 | 
			
		||||
  // Get the parent schemas that could contain this component
 | 
			
		||||
  const parentSchemas = getParentSchemasForComponent(componentSchema);
 | 
			
		||||
  if (parentSchemas.length === 0) {
 | 
			
		||||
    // No potential parents
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Find the actual parent for THIS specific component instance
 | 
			
		||||
  const parent = await findComponentParent(componentSchema, componentId, parentSchemas);
 | 
			
		||||
  if (!parent) {
 | 
			
		||||
    // No parent found for this component instance
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (strapi.components[parent.uid as keyof typeof strapi.components]) {
 | 
			
		||||
    // If the parent is a component, we need to check its parents recursively
 | 
			
		||||
    const parentComponentSchema = strapi.components[parent.uid as keyof typeof strapi.components];
 | 
			
		||||
    return findContentTypeParentForComponentInstance(parentComponentSchema, parent.parentId);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (strapi.contentTypes[parent.uid as keyof typeof strapi.contentTypes]) {
 | 
			
		||||
    // Found a content type parent
 | 
			
		||||
    return parent;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Finds join table entries with publication state mismatches
 | 
			
		||||
 * Uses existing component parent detection from document service
 | 
			
		||||
 */
 | 
			
		||||
const findPublicationStateMismatches = async (
 | 
			
		||||
  db: Database,
 | 
			
		||||
  joinTableName: string,
 | 
			
		||||
  relation: any,
 | 
			
		||||
  targetModel: any,
 | 
			
		||||
  sourceModel: any
 | 
			
		||||
): Promise<number[]> => {
 | 
			
		||||
  try {
 | 
			
		||||
    // Get join column names using proper functions (addressing PR feedback)
 | 
			
		||||
    const sourceColumn = relation.joinTable.joinColumn.name;
 | 
			
		||||
    const targetColumn = relation.joinTable.inverseJoinColumn.name;
 | 
			
		||||
 | 
			
		||||
    // Get all join entries with their target entities
 | 
			
		||||
    const query = db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select(
 | 
			
		||||
        `${joinTableName}.id as join_id`,
 | 
			
		||||
        `${joinTableName}.${sourceColumn} as source_id`,
 | 
			
		||||
        `${joinTableName}.${targetColumn} as target_id`,
 | 
			
		||||
        `${targetModel.tableName}.published_at as target_published_at`
 | 
			
		||||
      )
 | 
			
		||||
      .leftJoin(
 | 
			
		||||
        targetModel.tableName,
 | 
			
		||||
        `${joinTableName}.${targetColumn}`,
 | 
			
		||||
        `${targetModel.tableName}.id`
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    const joinEntries = await query;
 | 
			
		||||
 | 
			
		||||
    // Group by source_id to find duplicates pointing to draft/published versions of same entity
 | 
			
		||||
    const entriesBySource: { [key: string]: any[] } = {};
 | 
			
		||||
    for (const entry of joinEntries) {
 | 
			
		||||
      const sourceId = entry.source_id;
 | 
			
		||||
      if (!entriesBySource[sourceId]) {
 | 
			
		||||
        entriesBySource[sourceId] = [];
 | 
			
		||||
      }
 | 
			
		||||
      entriesBySource[sourceId].push(entry);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const ghostEntries: number[] = [];
 | 
			
		||||
 | 
			
		||||
    // Check if this is a join table (ends with _lnk)
 | 
			
		||||
    const isRelationJoinTable = joinTableName.endsWith('_lnk');
 | 
			
		||||
    const isComponentModel =
 | 
			
		||||
      !sourceModel.uid?.startsWith('api::') &&
 | 
			
		||||
      !sourceModel.uid?.startsWith('plugin::') &&
 | 
			
		||||
      sourceModel.uid?.includes('.');
 | 
			
		||||
 | 
			
		||||
    // Check for draft/publish inconsistencies
 | 
			
		||||
    for (const [sourceId, entries] of Object.entries(entriesBySource)) {
 | 
			
		||||
      // Skip entries with single relations
 | 
			
		||||
      if (entries.length <= 1) {
 | 
			
		||||
        // eslint-disable-next-line no-continue
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // For component join tables, check if THIS specific component instance's parent supports D&P
 | 
			
		||||
      if (isRelationJoinTable && isComponentModel) {
 | 
			
		||||
        try {
 | 
			
		||||
          const componentSchema = strapi.components[sourceModel.uid] as Schema.Component;
 | 
			
		||||
          if (!componentSchema) {
 | 
			
		||||
            // eslint-disable-next-line no-continue
 | 
			
		||||
            continue;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          const parent = await findContentTypeParentForComponentInstance(componentSchema, sourceId);
 | 
			
		||||
          if (!parent) {
 | 
			
		||||
            continue;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // Check if THIS component instance's parent supports draft/publish
 | 
			
		||||
          const parentContentType =
 | 
			
		||||
            strapi.contentTypes[parent.uid as keyof typeof strapi.contentTypes];
 | 
			
		||||
          if (!parentContentType?.options?.draftAndPublish) {
 | 
			
		||||
            // This component instance's parent does NOT support D&P - skip cleanup
 | 
			
		||||
            // eslint-disable-next-line no-continue
 | 
			
		||||
            continue;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // If we reach here, this component instance's parent DOES support D&P
 | 
			
		||||
          // Continue to process this component instance for ghost relations
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          // Skip this component instance on error
 | 
			
		||||
          // eslint-disable-next-line no-continue
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Find ghost relations (same logic as original but with improved parent checking)
 | 
			
		||||
      for (const entry of entries) {
 | 
			
		||||
        if (entry.target_published_at === null) {
 | 
			
		||||
          // This is a draft target - find its published version
 | 
			
		||||
          const draftTarget = await db
 | 
			
		||||
            .connection(targetModel.tableName)
 | 
			
		||||
            .select('document_id')
 | 
			
		||||
            .where('id', entry.target_id)
 | 
			
		||||
            .first();
 | 
			
		||||
 | 
			
		||||
          if (draftTarget) {
 | 
			
		||||
            const publishedVersion = await db
 | 
			
		||||
              .connection(targetModel.tableName)
 | 
			
		||||
              .select('id', 'document_id')
 | 
			
		||||
              .where('document_id', draftTarget.document_id)
 | 
			
		||||
              .whereNotNull('published_at')
 | 
			
		||||
              .first();
 | 
			
		||||
 | 
			
		||||
            if (publishedVersion) {
 | 
			
		||||
              // Check if we also have a relation to the published version
 | 
			
		||||
              const publishedRelation = entries.find((e) => e.target_id === publishedVersion.id);
 | 
			
		||||
              if (publishedRelation) {
 | 
			
		||||
                ghostEntries.push(publishedRelation.join_id);
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return ghostEntries;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    return [];
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
@ -1,10 +1,12 @@
 | 
			
		||||
import type { Database } from '..';
 | 
			
		||||
import { removeOrphanMorphType as removeOrphanMorphTypeFunc } from './operations/remove-orphan-morph-types';
 | 
			
		||||
import { processUnidirectionalJoinTables } from './operations/process-unidirectional-join-tables';
 | 
			
		||||
import { asyncCurry } from '../utils/async-curry';
 | 
			
		||||
 | 
			
		||||
export const createRepairManager = (db: Database) => {
 | 
			
		||||
  return {
 | 
			
		||||
    removeOrphanMorphType: asyncCurry(removeOrphanMorphTypeFunc)(db),
 | 
			
		||||
    processUnidirectionalJoinTables: asyncCurry(processUnidirectionalJoinTables)(db),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,79 @@
 | 
			
		||||
import type { Database } from '../..';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Iterates over all models and their unidirectional relations, invoking a provided operation on each join table.
 | 
			
		||||
 *
 | 
			
		||||
 * This function does not perform any cleaning or modification itself. Instead, it identifies all unidirectional
 | 
			
		||||
 * relations (relations without inversedBy or mappedBy) that use join tables, and delegates any join table operation
 | 
			
		||||
 * (such as cleaning, validation, or analysis) to the provided operateOnJoinTable function.
 | 
			
		||||
 *
 | 
			
		||||
 * @param db - The database instance
 | 
			
		||||
 * @param operateOnJoinTable - A function to execute for each unidirectional join table relation
 | 
			
		||||
 * @returns The sum of results returned by operateOnJoinTable for all processed relations
 | 
			
		||||
 */
 | 
			
		||||
export const processUnidirectionalJoinTables = async (
 | 
			
		||||
  db: Database,
 | 
			
		||||
  operateOnJoinTable: (
 | 
			
		||||
    db: Database,
 | 
			
		||||
    joinTableName: string,
 | 
			
		||||
    relation: any,
 | 
			
		||||
    sourceModel: any
 | 
			
		||||
  ) => Promise<number>
 | 
			
		||||
): Promise<number> => {
 | 
			
		||||
  let totalResult = 0;
 | 
			
		||||
 | 
			
		||||
  const mdValues = db.metadata.values();
 | 
			
		||||
  const mdArray = Array.from(mdValues);
 | 
			
		||||
 | 
			
		||||
  if (mdArray.length === 0) {
 | 
			
		||||
    return 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  db.logger.debug('Starting unidirectional join table operation');
 | 
			
		||||
 | 
			
		||||
  for (const model of mdArray) {
 | 
			
		||||
    const unidirectionalRelations = getUnidirectionalRelations(model.attributes || {});
 | 
			
		||||
 | 
			
		||||
    for (const relation of unidirectionalRelations) {
 | 
			
		||||
      if (hasJoinTable(relation) && hasTarget(relation)) {
 | 
			
		||||
        const result = await operateOnJoinTable(db, relation.joinTable.name, relation, model);
 | 
			
		||||
        totalResult += result;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  db.logger.debug(
 | 
			
		||||
    `Unidirectional join table operation completed. Processed ${totalResult} entries.`
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return totalResult;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Identifies unidirectional relations (relations without inversedBy or mappedBy)
 | 
			
		||||
 * Uses same logic as prevention fix in unidirectional-relations.ts:54-61
 | 
			
		||||
 */
 | 
			
		||||
const getUnidirectionalRelations = (attributes: Record<string, any>): any[] => {
 | 
			
		||||
  return Object.values(attributes).filter((attribute) => {
 | 
			
		||||
    if (attribute.type !== 'relation') {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check if it's unidirectional (no inversedBy or mappedBy) - same as prevention logic
 | 
			
		||||
    return !attribute.inversedBy && !attribute.mappedBy;
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Type guard to check if a relation has a joinTable property
 | 
			
		||||
 */
 | 
			
		||||
const hasJoinTable = (relation: any): boolean => {
 | 
			
		||||
  return 'joinTable' in relation && relation.joinTable != null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Type guard to check if a relation has a target property
 | 
			
		||||
 */
 | 
			
		||||
const hasTarget = (relation: any): boolean => {
 | 
			
		||||
  return 'target' in relation && typeof relation.target === 'string';
 | 
			
		||||
};
 | 
			
		||||
@ -17,6 +17,7 @@ let rq;
 | 
			
		||||
 | 
			
		||||
const PRODUCT_UID = 'api::product.product';
 | 
			
		||||
const TAG_UID = 'api::tag.tag';
 | 
			
		||||
const INNER_COMPONENT_UID = 'default.inner';
 | 
			
		||||
 | 
			
		||||
const populate = {
 | 
			
		||||
  tag: true,
 | 
			
		||||
@ -25,8 +26,25 @@ const populate = {
 | 
			
		||||
    populate: {
 | 
			
		||||
      tag: true,
 | 
			
		||||
      tags: true,
 | 
			
		||||
      inner: {
 | 
			
		||||
        populate: {
 | 
			
		||||
          tags: true,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const innerComponentModel = {
 | 
			
		||||
  collectionName: 'components_inner',
 | 
			
		||||
  attributes: {
 | 
			
		||||
    tags: {
 | 
			
		||||
      type: 'relation',
 | 
			
		||||
      relation: 'oneToMany',
 | 
			
		||||
      target: TAG_UID,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  displayName: 'inner',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const componentModel = {
 | 
			
		||||
@ -42,6 +60,10 @@ const componentModel = {
 | 
			
		||||
      relation: 'oneToMany',
 | 
			
		||||
      target: TAG_UID,
 | 
			
		||||
    },
 | 
			
		||||
    inner: {
 | 
			
		||||
      type: 'component',
 | 
			
		||||
      component: INNER_COMPONENT_UID,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  displayName: 'compo',
 | 
			
		||||
};
 | 
			
		||||
@ -92,6 +114,7 @@ describe('Document Service unidirectional relations', () => {
 | 
			
		||||
  beforeAll(async () => {
 | 
			
		||||
    await builder
 | 
			
		||||
      .addContentTypes([tagModel])
 | 
			
		||||
      .addComponent(innerComponentModel)
 | 
			
		||||
      .addComponent(componentModel)
 | 
			
		||||
      .addContentTypes([productModel])
 | 
			
		||||
      .build();
 | 
			
		||||
@ -114,7 +137,10 @@ describe('Document Service unidirectional relations', () => {
 | 
			
		||||
        name: 'Product1',
 | 
			
		||||
        tag: { documentId: 'Tag1' },
 | 
			
		||||
        tags: [{ documentId: 'Tag1' }, { documentId: 'Tag2' }],
 | 
			
		||||
        compo: { tag: { documentId: 'Tag1' } },
 | 
			
		||||
        compo: {
 | 
			
		||||
          tag: { documentId: 'Tag1' },
 | 
			
		||||
          inner: { tags: [{ documentId: 'Tag1' }, { documentId: 'Tag2' }] },
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -146,7 +172,7 @@ describe('Document Service unidirectional relations', () => {
 | 
			
		||||
      name: 'Product1',
 | 
			
		||||
      tag: { id: tag1Id },
 | 
			
		||||
      tags: [{ id: tag1Id }, { id: tag2Id }],
 | 
			
		||||
      compo: { tag: { id: tag1Id } },
 | 
			
		||||
      compo: { tag: { id: tag1Id }, inner: { tags: [{ id: tag1Id }, { id: tag2Id }] } },
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -168,7 +194,7 @@ describe('Document Service unidirectional relations', () => {
 | 
			
		||||
      name: 'Product1',
 | 
			
		||||
      tag: { id: tag1Id },
 | 
			
		||||
      tags: [{ id: tag1Id }, { id: tag2Id }],
 | 
			
		||||
      compo: { tag: { id: tag1Id } },
 | 
			
		||||
      compo: { tag: { id: tag1Id }, inner: { tags: [{ id: tag1Id }, { id: tag2Id }] } },
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -281,4 +307,144 @@ describe('Document Service unidirectional relations', () => {
 | 
			
		||||
    joinTableRows = Array.isArray(result) ? result : result.rows || result;
 | 
			
		||||
    expect(joinTableRows.length).toBe(0);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Helpers to resolve the nested component (inner) join table info at runtime
 | 
			
		||||
  const getInnerJoinInfo = () => {
 | 
			
		||||
    const md: any = (strapi as any).db.metadata.get(INNER_COMPONENT_UID);
 | 
			
		||||
    const relation: any = md.attributes?.tags;
 | 
			
		||||
    if (!relation?.joinTable?.name) throw new Error('Inner component join table not found');
 | 
			
		||||
    const joinTableName = relation.joinTable.name as string;
 | 
			
		||||
    const targetColumn = relation.joinTable.inverseJoinColumn.name as string;
 | 
			
		||||
    return { joinTableName, targetColumn } as const;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  it('Should not create orphaned relations for nested component when updating from the parent side', async () => {
 | 
			
		||||
    const { joinTableName, targetColumn } = getInnerJoinInfo();
 | 
			
		||||
 | 
			
		||||
    // Step 1: Create Product with nested component tag relation (draft)
 | 
			
		||||
    const testProduct = await strapi.documents(PRODUCT_UID).create({
 | 
			
		||||
      data: {
 | 
			
		||||
        name: 'NestedGhostRelationParent',
 | 
			
		||||
        compo: { inner: { tags: [{ documentId: 'Tag3' }] } },
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Load Tag3 versions and filter join table rows by Tag3 document ids
 | 
			
		||||
    let tag3Versions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'Tag3' } });
 | 
			
		||||
    const tag3Draft = tag3Versions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
    const tag3Published = tag3Versions.find((t: any) => t.publishedAt !== null);
 | 
			
		||||
 | 
			
		||||
    // 1 entry is created for draft to draft (only draft id exists at this stage)
 | 
			
		||||
    let joinTableRows = await strapi.db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select('*')
 | 
			
		||||
      .whereIn(targetColumn, [tag3Draft.id]);
 | 
			
		||||
    expect(joinTableRows.length).toBe(1);
 | 
			
		||||
 | 
			
		||||
    // Step 2: Publish Tag FIRST
 | 
			
		||||
    await strapi.documents(TAG_UID).publish({ documentId: 'Tag3' });
 | 
			
		||||
 | 
			
		||||
    tag3Versions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'Tag3' } });
 | 
			
		||||
    const tag3DraftAfter = tag3Versions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
    const tag3PubAfter = tag3Versions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
 | 
			
		||||
    // Still 1 row for Tag3 (should not create extra rows yet)
 | 
			
		||||
    joinTableRows = await strapi.db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select('*')
 | 
			
		||||
      .whereIn(targetColumn, [tag3DraftAfter.id, tag3PubAfter.id]);
 | 
			
		||||
    expect(joinTableRows.length).toBe(1);
 | 
			
		||||
 | 
			
		||||
    // Step 3: Publish Product - creates published component version
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).publish({ documentId: testProduct.documentId });
 | 
			
		||||
 | 
			
		||||
    joinTableRows = await strapi.db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select('*')
 | 
			
		||||
      .whereIn(targetColumn, [tag3DraftAfter.id, tag3PubAfter.id]);
 | 
			
		||||
    expect(joinTableRows.length).toBe(2);
 | 
			
		||||
 | 
			
		||||
    // Cleanup
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).delete({ documentId: testProduct.documentId });
 | 
			
		||||
    joinTableRows = await strapi.db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select('*')
 | 
			
		||||
      .whereIn(targetColumn, [tag3DraftAfter.id, tag3PubAfter.id]);
 | 
			
		||||
    expect(joinTableRows.length).toBe(0);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('Should not create orphaned relations for nested component when updating from the relation side', async () => {
 | 
			
		||||
    const { joinTableName, targetColumn } = getInnerJoinInfo();
 | 
			
		||||
 | 
			
		||||
    // Step 1: Create and publish a tag
 | 
			
		||||
    await strapi.documents(TAG_UID).create({ data: { name: 'Tag5', documentId: 'Tag5' } });
 | 
			
		||||
    const tag = await strapi.documents(TAG_UID).publish({ documentId: 'Tag5' });
 | 
			
		||||
    const tagPublishedId = tag.entries[0].id;
 | 
			
		||||
    const tag5Versions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'Tag5' } });
 | 
			
		||||
    const tag5Draft = tag5Versions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
    const tag5Published = tag5Versions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
 | 
			
		||||
    // Step 2: Create Product with nested component tag relation (published)
 | 
			
		||||
    const testProduct = await strapi.documents(PRODUCT_UID).create({
 | 
			
		||||
      data: {
 | 
			
		||||
        name: 'NestedGhostRelationRel',
 | 
			
		||||
        compo: { inner: { tags: [{ id: tagPublishedId }] } },
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Step 3: Publish the product
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).publish({ documentId: testProduct.documentId });
 | 
			
		||||
 | 
			
		||||
    // Expect 2 entries (draft and published) for Tag5 document (either id)
 | 
			
		||||
    let joinTableRows = await strapi.db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select('*')
 | 
			
		||||
      .whereIn(targetColumn, [tag5Draft.id, tag5Published.id]);
 | 
			
		||||
    expect(joinTableRows.length).toBe(2);
 | 
			
		||||
 | 
			
		||||
    // Step 4: Update the tag and publish
 | 
			
		||||
    await strapi.documents(TAG_UID).update({ documentId: 'Tag5', name: 'Tag5 update' });
 | 
			
		||||
    await strapi.documents(TAG_UID).publish({ documentId: 'Tag5' });
 | 
			
		||||
 | 
			
		||||
    // Re-fetch Tag5 versions since publish created new ids
 | 
			
		||||
    {
 | 
			
		||||
      const updatedTag5Versions = await strapi.db
 | 
			
		||||
        .query(TAG_UID)
 | 
			
		||||
        .findMany({ where: { documentId: 'Tag5' } });
 | 
			
		||||
      const newDraft = updatedTag5Versions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
      const newPublished = updatedTag5Versions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
 | 
			
		||||
      joinTableRows = await strapi.db
 | 
			
		||||
        .connection(joinTableName)
 | 
			
		||||
        .select('*')
 | 
			
		||||
        .whereIn(targetColumn, [newDraft.id, newPublished.id]);
 | 
			
		||||
      expect(joinTableRows.length).toBe(2);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Step 5: Republish the parent
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).publish({ documentId: testProduct.documentId });
 | 
			
		||||
 | 
			
		||||
    // Re-check using current Tag5 ids after republishing the parent
 | 
			
		||||
    {
 | 
			
		||||
      const currentTag5Versions = await strapi.db
 | 
			
		||||
        .query(TAG_UID)
 | 
			
		||||
        .findMany({ where: { documentId: 'Tag5' } });
 | 
			
		||||
      const curDraft = currentTag5Versions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
      const curPublished = currentTag5Versions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
 | 
			
		||||
      joinTableRows = await strapi.db
 | 
			
		||||
        .connection(joinTableName)
 | 
			
		||||
        .select('*')
 | 
			
		||||
        .whereIn(targetColumn, [curDraft.id, curPublished.id]);
 | 
			
		||||
      expect(joinTableRows.length).toBe(2);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Cleanup - Delete the entry
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).delete({ documentId: testProduct.documentId });
 | 
			
		||||
    joinTableRows = await strapi.db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select('*')
 | 
			
		||||
      .whereIn(targetColumn, [tag5Draft.id, tag5Published.id]);
 | 
			
		||||
    expect(joinTableRows.length).toBe(0);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,725 @@
 | 
			
		||||
/**
 | 
			
		||||
 * API tests for the unidirectional join-table repair.
 | 
			
		||||
 *
 | 
			
		||||
 * These tests insert bad data directly into the DB to simulate the original bug:
 | 
			
		||||
 * duplicated component join-table rows pointing to both the draft and the
 | 
			
		||||
 * published versions of the same target document for a single component instance.
 | 
			
		||||
 *
 | 
			
		||||
 * We validate that:
 | 
			
		||||
 * - Only the unintended duplicate is removed (published row when a draft row also exists)
 | 
			
		||||
 * - Legitimate rows are never deleted (single relations remain)
 | 
			
		||||
 * - If the component's parent does not support D&P, nothing is deleted
 | 
			
		||||
 */
 | 
			
		||||
import type { Core } from '@strapi/types';
 | 
			
		||||
// Note: Avoid wrapping in a transaction to prevent pool deadlocks with SQLite
 | 
			
		||||
 | 
			
		||||
const { createTestBuilder } = require('api-tests/builder');
 | 
			
		||||
const { createStrapiInstance } = require('api-tests/strapi');
 | 
			
		||||
 | 
			
		||||
// Local cleaner used for tests to have full control and avoid coupling to core internals
 | 
			
		||||
const testCleaner = async (
 | 
			
		||||
  db: any,
 | 
			
		||||
  joinTableName: string,
 | 
			
		||||
  relation: any,
 | 
			
		||||
  sourceModel: any
 | 
			
		||||
): Promise<number> => {
 | 
			
		||||
  try {
 | 
			
		||||
    const targetModel = db.metadata.get(relation.target);
 | 
			
		||||
    if (!targetModel) {
 | 
			
		||||
      return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check if target supports D&P
 | 
			
		||||
    const targetCt = (strapi as any).contentTypes?.[relation.target];
 | 
			
		||||
    const targetSupportsDP = !!targetCt?.options?.draftAndPublish;
 | 
			
		||||
    if (!targetSupportsDP) {
 | 
			
		||||
      return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Column names
 | 
			
		||||
    const sourceColumn = relation.joinTable.joinColumn.name;
 | 
			
		||||
    const targetColumn = relation.joinTable.inverseJoinColumn.name;
 | 
			
		||||
 | 
			
		||||
    // Load join rows with target published_at
 | 
			
		||||
    const rows = await db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select(
 | 
			
		||||
        `${joinTableName}.id as join_id`,
 | 
			
		||||
        `${joinTableName}.${sourceColumn} as source_id`,
 | 
			
		||||
        `${joinTableName}.${targetColumn} as target_id`,
 | 
			
		||||
        `${targetModel.tableName}.published_at as target_published_at`,
 | 
			
		||||
        `${targetModel.tableName}.document_id as target_document_id`
 | 
			
		||||
      )
 | 
			
		||||
      .leftJoin(
 | 
			
		||||
        targetModel.tableName,
 | 
			
		||||
        `${joinTableName}.${targetColumn}`,
 | 
			
		||||
        `${targetModel.tableName}.id`
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    // Group by component instance (source)
 | 
			
		||||
    const bySource: Record<string, any[]> = {};
 | 
			
		||||
    for (const r of rows) {
 | 
			
		||||
      const key = String(r.source_id);
 | 
			
		||||
      bySource[key] = bySource[key] || [];
 | 
			
		||||
      bySource[key].push(r);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Resolve table names for our parents to detect D&P support
 | 
			
		||||
    const productMd = db.metadata.get(PRODUCT_UID);
 | 
			
		||||
    const boxMd = db.metadata.get(BOX_UID);
 | 
			
		||||
    const productCmpsTable = productMd?.tableName ? `${productMd.tableName}_cmps` : undefined;
 | 
			
		||||
    const boxCmpsTable = boxMd?.tableName ? `${boxMd.tableName}_cmps` : undefined;
 | 
			
		||||
 | 
			
		||||
    const toDelete: number[] = [];
 | 
			
		||||
 | 
			
		||||
    for (const [sourceId, entries] of Object.entries(bySource)) {
 | 
			
		||||
      if (entries.length <= 1) continue;
 | 
			
		||||
 | 
			
		||||
      // Parent D&P check: find component row to get entity_id
 | 
			
		||||
      let parentSupportsDP = true;
 | 
			
		||||
      try {
 | 
			
		||||
        // Prefer mapping tables to detect parent type reliably
 | 
			
		||||
        let productParent = null;
 | 
			
		||||
        let boxParent = null;
 | 
			
		||||
        if (productCmpsTable) {
 | 
			
		||||
          productParent = await db
 | 
			
		||||
            .connection(productCmpsTable)
 | 
			
		||||
            .select('entity_id')
 | 
			
		||||
            .where('cmp_id', Number(sourceId))
 | 
			
		||||
            .first();
 | 
			
		||||
        }
 | 
			
		||||
        if (!productParent && boxCmpsTable) {
 | 
			
		||||
          boxParent = await db
 | 
			
		||||
            .connection(boxCmpsTable)
 | 
			
		||||
            .select('entity_id')
 | 
			
		||||
            .where('cmp_id', Number(sourceId))
 | 
			
		||||
            .first();
 | 
			
		||||
        }
 | 
			
		||||
        if (boxParent) parentSupportsDP = false;
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        // If any error, default to safe side: do not delete
 | 
			
		||||
        parentSupportsDP = false;
 | 
			
		||||
      }
 | 
			
		||||
      if (!parentSupportsDP) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // For each document, if both draft and published relations exist, delete the published one(s)
 | 
			
		||||
      const byDoc: Record<string, any[]> = {};
 | 
			
		||||
      for (const e of entries) {
 | 
			
		||||
        const key = String(e.target_document_id ?? '');
 | 
			
		||||
        byDoc[key] = byDoc[key] || [];
 | 
			
		||||
        byDoc[key].push(e);
 | 
			
		||||
      }
 | 
			
		||||
      for (const [docId, docEntries] of Object.entries(byDoc)) {
 | 
			
		||||
        if (!docId || docEntries.length <= 1) continue;
 | 
			
		||||
        const publishedEntries = docEntries.filter((e) => e.target_published_at !== null);
 | 
			
		||||
        const draftEntries = docEntries.filter((e) => e.target_published_at === null);
 | 
			
		||||
        if (publishedEntries.length > 0 && draftEntries.length > 0) {
 | 
			
		||||
          for (const pub of publishedEntries) toDelete.push(pub.join_id);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (toDelete.length > 0) {
 | 
			
		||||
      await db.connection(joinTableName).whereIn('id', toDelete).del();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return toDelete.length;
 | 
			
		||||
  } catch (err) {
 | 
			
		||||
    return 0;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
let strapi: Core.Strapi;
 | 
			
		||||
const builder = createTestBuilder();
 | 
			
		||||
 | 
			
		||||
const TAG_UID = 'api::repair-tag.repair-tag';
 | 
			
		||||
const PRODUCT_UID = 'api::repair-product.repair-product';
 | 
			
		||||
const BOX_UID = 'api::repair-box.repair-box';
 | 
			
		||||
const ARTICLE_UID = 'api::repair-article.repair-article';
 | 
			
		||||
const REPAIR_COMPONENT_UID = 'default.repair-compo';
 | 
			
		||||
const REPAIR_INNER_COMPONENT_UID = 'default.repair-inner';
 | 
			
		||||
 | 
			
		||||
const innerComponentModel = {
 | 
			
		||||
  collectionName: 'components_repair_inner',
 | 
			
		||||
  attributes: {
 | 
			
		||||
    rtags: {
 | 
			
		||||
      type: 'relation',
 | 
			
		||||
      relation: 'oneToMany',
 | 
			
		||||
      target: TAG_UID,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  displayName: 'repair-inner',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const componentModel = {
 | 
			
		||||
  collectionName: 'components_repair_compo',
 | 
			
		||||
  attributes: {
 | 
			
		||||
    rtag: {
 | 
			
		||||
      type: 'relation',
 | 
			
		||||
      relation: 'oneToOne',
 | 
			
		||||
      target: TAG_UID,
 | 
			
		||||
    },
 | 
			
		||||
    rtags: {
 | 
			
		||||
      type: 'relation',
 | 
			
		||||
      relation: 'oneToMany',
 | 
			
		||||
      target: TAG_UID,
 | 
			
		||||
    },
 | 
			
		||||
    inner: {
 | 
			
		||||
      type: 'component',
 | 
			
		||||
      component: REPAIR_INNER_COMPONENT_UID,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  displayName: 'repair-compo',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const productModel = {
 | 
			
		||||
  attributes: {
 | 
			
		||||
    name: { type: 'string' },
 | 
			
		||||
    rcompo: { type: 'component', component: REPAIR_COMPONENT_UID },
 | 
			
		||||
  },
 | 
			
		||||
  draftAndPublish: true,
 | 
			
		||||
  displayName: 'Repair Product',
 | 
			
		||||
  singularName: 'repair-product',
 | 
			
		||||
  pluralName: 'repair-products',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const tagModel = {
 | 
			
		||||
  attributes: {
 | 
			
		||||
    name: { type: 'string' },
 | 
			
		||||
  },
 | 
			
		||||
  draftAndPublish: true,
 | 
			
		||||
  displayName: 'Repair Tag',
 | 
			
		||||
  singularName: 'repair-tag',
 | 
			
		||||
  pluralName: 'repair-tags',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Non-component content type with a unidirectional many-to-many relation to tags
 | 
			
		||||
const articleModel = {
 | 
			
		||||
  attributes: {
 | 
			
		||||
    title: { type: 'string' },
 | 
			
		||||
    tags: {
 | 
			
		||||
      type: 'relation',
 | 
			
		||||
      relation: 'manyToMany',
 | 
			
		||||
      target: TAG_UID,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  draftAndPublish: true,
 | 
			
		||||
  displayName: 'Repair Article',
 | 
			
		||||
  singularName: 'repair-article',
 | 
			
		||||
  pluralName: 'repair-articles',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Parent without D&P that still embeds the component
 | 
			
		||||
const boxModel = {
 | 
			
		||||
  attributes: {
 | 
			
		||||
    title: { type: 'string' },
 | 
			
		||||
    rcompo: { type: 'component', component: REPAIR_COMPONENT_UID },
 | 
			
		||||
  },
 | 
			
		||||
  draftAndPublish: false,
 | 
			
		||||
  displayName: 'Repair Box',
 | 
			
		||||
  singularName: 'repair-box',
 | 
			
		||||
  pluralName: 'repair-boxes',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Helper to locate component->rtags join table and its column names from metadata
 | 
			
		||||
const getCompoTagsRelationInfo = () => {
 | 
			
		||||
  const componentMd: any = (strapi as any).db.metadata.get(REPAIR_COMPONENT_UID);
 | 
			
		||||
  if (!componentMd) {
 | 
			
		||||
    throw new Error('Repair component metadata not found');
 | 
			
		||||
  }
 | 
			
		||||
  const relation: any = componentMd.attributes?.rtags;
 | 
			
		||||
  if (!relation?.joinTable) {
 | 
			
		||||
    throw new Error('Repair component rtags relation not found');
 | 
			
		||||
  }
 | 
			
		||||
  const joinTableName = relation.joinTable.name;
 | 
			
		||||
  const sourceColumn = relation.joinTable.joinColumn.name;
 | 
			
		||||
  const targetColumn = relation.joinTable.inverseJoinColumn.name;
 | 
			
		||||
  return { joinTableName, sourceColumn, targetColumn } as const;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Helper to locate InnerComponent->rtags join table and its column names from metadata
 | 
			
		||||
const getInnerCompoTagsRelationInfo = () => {
 | 
			
		||||
  const componentMd: any = (strapi as any).db.metadata.get(REPAIR_INNER_COMPONENT_UID);
 | 
			
		||||
  if (!componentMd) {
 | 
			
		||||
    throw new Error('Repair inner component metadata not found');
 | 
			
		||||
  }
 | 
			
		||||
  const relation: any = componentMd.attributes?.rtags;
 | 
			
		||||
  if (!relation?.joinTable) {
 | 
			
		||||
    throw new Error('Repair inner component rtags relation not found');
 | 
			
		||||
  }
 | 
			
		||||
  const joinTableName = relation.joinTable.name;
 | 
			
		||||
  const sourceColumn = relation.joinTable.joinColumn.name;
 | 
			
		||||
  const targetColumn = relation.joinTable.inverseJoinColumn.name;
 | 
			
		||||
  return { joinTableName, sourceColumn, targetColumn } as const;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Helper to locate Article->tags join table and its column names from metadata
 | 
			
		||||
const getArticleTagsRelationInfo = () => {
 | 
			
		||||
  const articleMd: any = (strapi as any).db.metadata.get(ARTICLE_UID);
 | 
			
		||||
  if (!articleMd) {
 | 
			
		||||
    throw new Error('Repair article metadata not found');
 | 
			
		||||
  }
 | 
			
		||||
  const relation: any = articleMd.attributes?.tags;
 | 
			
		||||
  if (!relation?.joinTable) {
 | 
			
		||||
    throw new Error('Repair article tags relation not found');
 | 
			
		||||
  }
 | 
			
		||||
  const joinTableName = relation.joinTable.name;
 | 
			
		||||
  const sourceColumn = relation.joinTable.joinColumn.name;
 | 
			
		||||
  const targetColumn = relation.joinTable.inverseJoinColumn.name;
 | 
			
		||||
  return { joinTableName, sourceColumn, targetColumn } as const;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const selectAll = async (table: string) => {
 | 
			
		||||
  const rows = await strapi.db.connection(table).select('*');
 | 
			
		||||
  return rows as any[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const selectBySource = async (table: string, sourceColumn: string, sourceId: number) => {
 | 
			
		||||
  const rows = await strapi.db.connection(table).select('*').where(sourceColumn, sourceId);
 | 
			
		||||
  return rows as any[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
describe('Unidirectional join-table repair (components)', () => {
 | 
			
		||||
  beforeAll(async () => {
 | 
			
		||||
    await builder
 | 
			
		||||
      .addContentTypes([tagModel])
 | 
			
		||||
      .addComponent(innerComponentModel)
 | 
			
		||||
      .addComponent(componentModel)
 | 
			
		||||
      .addContentTypes([productModel, boxModel, articleModel])
 | 
			
		||||
      .build();
 | 
			
		||||
 | 
			
		||||
    strapi = await createStrapiInstance({ logLevel: 'error' });
 | 
			
		||||
 | 
			
		||||
    // Seed baseline tag used across tests (draft+published)
 | 
			
		||||
    await strapi.db
 | 
			
		||||
      .query(TAG_UID)
 | 
			
		||||
      .create({ data: { documentId: 'GTagR', name: 'GhostTagR', publishedAt: null } });
 | 
			
		||||
    await strapi.documents(TAG_UID).publish({ documentId: 'GTagR' });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  afterAll(async () => {
 | 
			
		||||
    await strapi.destroy();
 | 
			
		||||
    await builder.cleanup();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('Removes duplicate published row when both draft and published exist for one component instance', async () => {
 | 
			
		||||
    const { joinTableName, sourceColumn, targetColumn } = getCompoTagsRelationInfo();
 | 
			
		||||
 | 
			
		||||
    // Get draft & published tag IDs for documentId GTagR
 | 
			
		||||
    const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
 | 
			
		||||
    const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
    const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
 | 
			
		||||
    // Create draft product with a component relation to the draft tag
 | 
			
		||||
    const product = await strapi.documents(PRODUCT_UID).create({
 | 
			
		||||
      data: {
 | 
			
		||||
        name: 'Product-with-ghost',
 | 
			
		||||
        rcompo: { rtags: [{ documentId: 'GTagR', status: 'draft' }] },
 | 
			
		||||
      },
 | 
			
		||||
      status: 'draft',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // There should be a single draft relation row for the component instance
 | 
			
		||||
    let rows = await selectAll(joinTableName);
 | 
			
		||||
 | 
			
		||||
    expect(rows.length).toBe(1);
 | 
			
		||||
    const draftRow = rows[0];
 | 
			
		||||
    expect(draftRow[targetColumn]).toBe(draftTag.id);
 | 
			
		||||
 | 
			
		||||
    // Insert an unintended duplicate pointing to the published tag for the same source
 | 
			
		||||
    const sourceId = draftRow[sourceColumn];
 | 
			
		||||
    const insertRow = { ...draftRow };
 | 
			
		||||
    delete (insertRow as any).id;
 | 
			
		||||
    insertRow[targetColumn] = publishedTag.id;
 | 
			
		||||
    await strapi.db.connection(joinTableName).insert(insertRow);
 | 
			
		||||
 | 
			
		||||
    rows = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
 | 
			
		||||
    expect(rows.length).toBe(2);
 | 
			
		||||
    const targetIds = rows.map((r) => r[targetColumn]).sort();
 | 
			
		||||
    expect(targetIds).toEqual([draftTag.id, publishedTag.id].sort());
 | 
			
		||||
 | 
			
		||||
    const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
 | 
			
		||||
 | 
			
		||||
    expect(removed).toBeGreaterThanOrEqual(1);
 | 
			
		||||
 | 
			
		||||
    // Only the published duplicate should be removed; the draft relation must remain
 | 
			
		||||
    rows = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
 | 
			
		||||
    expect(rows.length).toBe(1);
 | 
			
		||||
    expect(rows[0][targetColumn]).toBe(draftTag.id);
 | 
			
		||||
 | 
			
		||||
    // Cleanup created product
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).delete({ documentId: product.documentId });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('Repairs duplicates for nested component relations (inner component -> tags)', async () => {
 | 
			
		||||
    const { joinTableName, sourceColumn, targetColumn } = getInnerCompoTagsRelationInfo();
 | 
			
		||||
 | 
			
		||||
    // Get draft & published tag IDs for documentId GTagR
 | 
			
		||||
    const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
 | 
			
		||||
    const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
    const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
 | 
			
		||||
    // Create draft product with a nested component relation to the draft tag
 | 
			
		||||
    const product = await strapi.documents(PRODUCT_UID).create({
 | 
			
		||||
      data: {
 | 
			
		||||
        name: 'Product-nested-ghost',
 | 
			
		||||
        rcompo: { inner: { rtags: [{ documentId: 'GTagR', status: 'draft' }] } },
 | 
			
		||||
      },
 | 
			
		||||
      status: 'draft',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // There should be a single draft relation row for the INNER component instance
 | 
			
		||||
    let rows = await selectAll(joinTableName);
 | 
			
		||||
 | 
			
		||||
    expect(rows.length).toBe(1);
 | 
			
		||||
    const draftRow = rows[0];
 | 
			
		||||
    expect(draftRow[targetColumn]).toBe(draftTag.id);
 | 
			
		||||
 | 
			
		||||
    // Insert an unintended duplicate pointing to the published tag for the same INNER source
 | 
			
		||||
    const sourceId = draftRow[sourceColumn];
 | 
			
		||||
    const insertRow = { ...draftRow } as any;
 | 
			
		||||
    delete insertRow.id;
 | 
			
		||||
    insertRow[targetColumn] = publishedTag.id;
 | 
			
		||||
    await strapi.db.connection(joinTableName).insert(insertRow);
 | 
			
		||||
 | 
			
		||||
    rows = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
 | 
			
		||||
    expect(rows.length).toBe(2);
 | 
			
		||||
    const targetIds = rows.map((r) => r[targetColumn]).sort();
 | 
			
		||||
    expect(targetIds).toEqual([draftTag.id, publishedTag.id].sort());
 | 
			
		||||
 | 
			
		||||
    const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
 | 
			
		||||
 | 
			
		||||
    expect(removed).toBeGreaterThanOrEqual(1);
 | 
			
		||||
 | 
			
		||||
    // Only the published duplicate should be removed; the draft relation must remain
 | 
			
		||||
    rows = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
 | 
			
		||||
    expect(rows.length).toBe(1);
 | 
			
		||||
    expect(rows[0][targetColumn]).toBe(draftTag.id);
 | 
			
		||||
 | 
			
		||||
    // Cleanup created product
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).delete({ documentId: product.documentId });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('Does not delete single relations (safety) for draft-only or published-only', async () => {
 | 
			
		||||
    const { joinTableName, sourceColumn, targetColumn } = getCompoTagsRelationInfo();
 | 
			
		||||
 | 
			
		||||
    const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
 | 
			
		||||
    const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
    const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
 | 
			
		||||
    // Draft-only relation: product in draft referencing draft tag
 | 
			
		||||
    const draftOnly = await strapi.documents(PRODUCT_UID).create({
 | 
			
		||||
      data: { name: 'Draft-only', rcompo: { rtags: [{ documentId: 'GTagR', status: 'draft' }] } },
 | 
			
		||||
      status: 'draft',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Published-only relation: product in draft referencing published tag by id
 | 
			
		||||
    const publishedOnly = await strapi.documents(PRODUCT_UID).create({
 | 
			
		||||
      data: { name: 'Published-only', rcompo: { rtags: [{ id: publishedTag.id }] } },
 | 
			
		||||
      status: 'draft',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Collect rows per component instance
 | 
			
		||||
    const rowsAll = await selectAll(joinTableName);
 | 
			
		||||
 | 
			
		||||
    const compoIdsByName: Record<string, number> = {};
 | 
			
		||||
    // Find the two component instances by correlating to tag ids present
 | 
			
		||||
    // We find component instances having a single row pointing to draftTag or publishedTag respectively
 | 
			
		||||
    const bySource: Record<number, any[]> = {};
 | 
			
		||||
    for (const row of rowsAll) {
 | 
			
		||||
      const sid = row[sourceColumn];
 | 
			
		||||
      bySource[sid] = bySource[sid] || [];
 | 
			
		||||
      bySource[sid].push(row);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const sources = Object.entries(bySource).filter(([, v]) => v.length === 1);
 | 
			
		||||
    expect(sources.length).toBeGreaterThanOrEqual(2);
 | 
			
		||||
 | 
			
		||||
    const draftOnlySource = sources.find(
 | 
			
		||||
      ([, [r]]) => r[targetColumn] === draftTag.id
 | 
			
		||||
    )![0] as unknown as number;
 | 
			
		||||
    const publishedOnlySource = sources.find(
 | 
			
		||||
      ([, [r]]) => r[targetColumn] === publishedTag.id
 | 
			
		||||
    )![0] as unknown as number;
 | 
			
		||||
    compoIdsByName['draft'] = Number(draftOnlySource);
 | 
			
		||||
    compoIdsByName['published'] = Number(publishedOnlySource);
 | 
			
		||||
 | 
			
		||||
    const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
 | 
			
		||||
 | 
			
		||||
    expect(removed).toBeGreaterThanOrEqual(0);
 | 
			
		||||
 | 
			
		||||
    // Ensure both single relations are untouched
 | 
			
		||||
    const draftRows = await selectBySource(joinTableName, sourceColumn, compoIdsByName['draft']);
 | 
			
		||||
 | 
			
		||||
    expect(draftRows.length).toBe(1);
 | 
			
		||||
    expect(draftRows[0][targetColumn]).toBe(draftTag.id);
 | 
			
		||||
 | 
			
		||||
    const publishedRows = await selectBySource(
 | 
			
		||||
      joinTableName,
 | 
			
		||||
      sourceColumn,
 | 
			
		||||
      compoIdsByName['published']
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    expect(publishedRows.length).toBe(1);
 | 
			
		||||
    expect(publishedRows[0][targetColumn]).toBe(publishedTag.id);
 | 
			
		||||
 | 
			
		||||
    // Cleanup
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).delete({ documentId: draftOnly.documentId });
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).delete({ documentId: publishedOnly.documentId });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it("Does not delete when component's parent does not support D&P (safety)", async () => {
 | 
			
		||||
    const { joinTableName, sourceColumn, targetColumn } = getCompoTagsRelationInfo();
 | 
			
		||||
 | 
			
		||||
    const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
 | 
			
		||||
    const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
    const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
 | 
			
		||||
    // Create a Box (no D&P) with component relation to a draft tag via documents service
 | 
			
		||||
    const boxEntry = await strapi.documents(BOX_UID).create({
 | 
			
		||||
      data: {
 | 
			
		||||
        title: 'No-DP-Box',
 | 
			
		||||
        rcompo: {
 | 
			
		||||
          rtag: { documentId: 'GTagR', status: 'draft' },
 | 
			
		||||
          rtags: [{ documentId: 'GTagR', status: 'draft' }],
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Find the component instance id directly from the join table using the draft target
 | 
			
		||||
    const rowForDraft = await strapi.db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select('*')
 | 
			
		||||
      .where(targetColumn, draftTag.id)
 | 
			
		||||
      .first();
 | 
			
		||||
 | 
			
		||||
    expect(rowForDraft).toBeDefined();
 | 
			
		||||
    const sourceId = rowForDraft[sourceColumn];
 | 
			
		||||
 | 
			
		||||
    // Confirm at least one join row exists for this component instance
 | 
			
		||||
    let rows = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
 | 
			
		||||
    expect(rows.length).toBeGreaterThanOrEqual(1);
 | 
			
		||||
    const draftRow = rowForDraft;
 | 
			
		||||
 | 
			
		||||
    // Insert a published duplicate for the same component instance
 | 
			
		||||
    const duplicate = { ...draftRow };
 | 
			
		||||
    delete (duplicate as any).id;
 | 
			
		||||
    duplicate[targetColumn] = publishedTag.id;
 | 
			
		||||
    await strapi.db.connection(joinTableName).insert(duplicate);
 | 
			
		||||
 | 
			
		||||
    rows = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
 | 
			
		||||
    expect(rows.length).toBe(2);
 | 
			
		||||
 | 
			
		||||
    // Run repair - should skip because parent (BOX) has no draftAndPublish
 | 
			
		||||
    const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
 | 
			
		||||
 | 
			
		||||
    expect(removed).toBeGreaterThanOrEqual(0);
 | 
			
		||||
 | 
			
		||||
    // Both rows must remain
 | 
			
		||||
    rows = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
 | 
			
		||||
    expect(rows.length).toBe(2);
 | 
			
		||||
 | 
			
		||||
    // Cleanup created box entry
 | 
			
		||||
    await strapi.documents(BOX_UID).delete({ documentId: boxEntry.documentId });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('Cleans duplicates across multiple component instances in a single run', async () => {
 | 
			
		||||
    const { joinTableName, sourceColumn, targetColumn } = getCompoTagsRelationInfo();
 | 
			
		||||
 | 
			
		||||
    // Resolve draft and published tag ids
 | 
			
		||||
    const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
 | 
			
		||||
    const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
    const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
 | 
			
		||||
    // Create two draft products each linking the component to the draft tag (distinct component instances)
 | 
			
		||||
    const prodA = await strapi.documents(PRODUCT_UID).create({
 | 
			
		||||
      data: { name: 'Multi-A', rcompo: { rtags: [{ documentId: 'GTagR', status: 'draft' }] } },
 | 
			
		||||
      status: 'draft',
 | 
			
		||||
    });
 | 
			
		||||
    const prodB = await strapi.documents(PRODUCT_UID).create({
 | 
			
		||||
      data: { name: 'Multi-B', rcompo: { rtags: [{ documentId: 'GTagR', status: 'draft' }] } },
 | 
			
		||||
      status: 'draft',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Identify the two component instances by selecting rows pointing to the draft tag
 | 
			
		||||
    const draftRowsAll = await strapi.db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select('*')
 | 
			
		||||
      .where(targetColumn, draftTag.id);
 | 
			
		||||
    expect(draftRowsAll.length).toBeGreaterThanOrEqual(2);
 | 
			
		||||
 | 
			
		||||
    // Take two distinct sources
 | 
			
		||||
    const uniqueSources: number[] = [];
 | 
			
		||||
    for (const r of draftRowsAll) {
 | 
			
		||||
      const sid = r[sourceColumn];
 | 
			
		||||
      if (!uniqueSources.includes(sid)) uniqueSources.push(sid);
 | 
			
		||||
      if (uniqueSources.length === 2) break;
 | 
			
		||||
    }
 | 
			
		||||
    expect(uniqueSources.length).toBe(2);
 | 
			
		||||
 | 
			
		||||
    // For each source, insert a duplicate row pointing to the published tag
 | 
			
		||||
    for (const srcId of uniqueSources) {
 | 
			
		||||
      const baseRow = draftRowsAll.find((r) => r[sourceColumn] === srcId)!;
 | 
			
		||||
      const dup = { ...baseRow } as any;
 | 
			
		||||
      delete dup.id;
 | 
			
		||||
      dup[targetColumn] = publishedTag.id;
 | 
			
		||||
      await strapi.db.connection(joinTableName).insert(dup);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Sanity: each selected source should now have both draft and published
 | 
			
		||||
    for (const srcId of uniqueSources) {
 | 
			
		||||
      const rows = await selectBySource(joinTableName, sourceColumn, srcId);
 | 
			
		||||
      const targets = rows.map((r) => r[targetColumn]);
 | 
			
		||||
      expect(targets).toEqual(expect.arrayContaining([draftTag.id, publishedTag.id]));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Run repair once, should remove the published duplicates for both sources
 | 
			
		||||
    const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
 | 
			
		||||
    expect(removed).toBeGreaterThanOrEqual(2);
 | 
			
		||||
 | 
			
		||||
    // Verify only the draft relation remains for each source
 | 
			
		||||
    for (const srcId of uniqueSources) {
 | 
			
		||||
      const rows = await selectBySource(joinTableName, sourceColumn, srcId);
 | 
			
		||||
      expect(rows.length).toBe(1);
 | 
			
		||||
      expect(rows[0][targetColumn]).toBe(draftTag.id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Cleanup products
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).delete({ documentId: prodA.documentId });
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).delete({ documentId: prodB.documentId });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('Only removes per-document published duplicates; different documents remain linked', async () => {
 | 
			
		||||
    const { joinTableName, sourceColumn, targetColumn } = getCompoTagsRelationInfo();
 | 
			
		||||
 | 
			
		||||
    // Create a second tag document so we can link two different documents from the same component instance
 | 
			
		||||
    await strapi.db
 | 
			
		||||
      .query(TAG_UID)
 | 
			
		||||
      .create({ data: { documentId: 'GTagS', name: 'GhostTagS', publishedAt: null } });
 | 
			
		||||
    await strapi.documents(TAG_UID).publish({ documentId: 'GTagS' });
 | 
			
		||||
 | 
			
		||||
    const tagRVersions = await strapi.db
 | 
			
		||||
      .query(TAG_UID)
 | 
			
		||||
      .findMany({ where: { documentId: 'GTagR' } });
 | 
			
		||||
    const tagSVersions = await strapi.db
 | 
			
		||||
      .query(TAG_UID)
 | 
			
		||||
      .findMany({ where: { documentId: 'GTagS' } });
 | 
			
		||||
    const draftR = tagRVersions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
    const publishedR = tagRVersions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
    const draftS = tagSVersions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
    const publishedS = tagSVersions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
 | 
			
		||||
    // One product with a component instance
 | 
			
		||||
    const product = await strapi.documents(PRODUCT_UID).create({
 | 
			
		||||
      data: {
 | 
			
		||||
        name: 'Mixed-targets',
 | 
			
		||||
        rcompo: { rtags: [{ documentId: 'GTagR', status: 'draft' }] },
 | 
			
		||||
      },
 | 
			
		||||
      status: 'draft',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Identify the source component instance (row pointing to draftR)
 | 
			
		||||
    const baseRow = await strapi.db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select('*')
 | 
			
		||||
      .where(targetColumn, draftR.id)
 | 
			
		||||
      .first();
 | 
			
		||||
    expect(baseRow).toBeDefined();
 | 
			
		||||
    const sourceId = baseRow[sourceColumn];
 | 
			
		||||
 | 
			
		||||
    // Insert a second row linking to another document (GTagS) using the draft version — legitimate
 | 
			
		||||
    const rowToS = { ...baseRow } as any;
 | 
			
		||||
    delete rowToS.id;
 | 
			
		||||
    rowToS[targetColumn] = draftS.id;
 | 
			
		||||
    await strapi.db.connection(joinTableName).insert(rowToS);
 | 
			
		||||
 | 
			
		||||
    // Also add a published link for GTagS to simulate mixed states across different documents (still legitimate)
 | 
			
		||||
    const rowToSPublished = { ...baseRow } as any;
 | 
			
		||||
    delete rowToSPublished.id;
 | 
			
		||||
    rowToSPublished[targetColumn] = publishedS.id;
 | 
			
		||||
    await strapi.db.connection(joinTableName).insert(rowToSPublished);
 | 
			
		||||
 | 
			
		||||
    // Sanity: we now have at least 3 rows for the same source (R-draft, S-draft, S-published)
 | 
			
		||||
    let rows = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
    expect(rows.length).toBeGreaterThanOrEqual(3);
 | 
			
		||||
 | 
			
		||||
    // Add the duplicate for R document: publishedR to make sure only that published duplicate is removed
 | 
			
		||||
    const rowToRPublished = { ...baseRow } as any;
 | 
			
		||||
    delete rowToRPublished.id;
 | 
			
		||||
    rowToRPublished[targetColumn] = publishedR.id;
 | 
			
		||||
    await strapi.db.connection(joinTableName).insert(rowToRPublished);
 | 
			
		||||
 | 
			
		||||
    rows = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
    // We should have at least: R-draft, R-published, S-draft, S-published
 | 
			
		||||
    expect(rows.length).toBeGreaterThanOrEqual(4);
 | 
			
		||||
 | 
			
		||||
    const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
 | 
			
		||||
    // Only the published duplicate for document R (which has both draft+published) should be removed
 | 
			
		||||
    expect(removed).toBeGreaterThanOrEqual(1);
 | 
			
		||||
 | 
			
		||||
    // Post-conditions: R-draft remains; S-draft remains; S-published is also removed (per-document rule)
 | 
			
		||||
    const after = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
    const targetIds = after.map((r) => r[targetColumn]);
 | 
			
		||||
    expect(after.length).toBe(2);
 | 
			
		||||
    expect(targetIds).toEqual(expect.arrayContaining([draftR.id, draftS.id]));
 | 
			
		||||
    expect(targetIds).not.toEqual(expect.arrayContaining([publishedR.id, publishedS.id]));
 | 
			
		||||
 | 
			
		||||
    // Cleanup created product and secondary tag document
 | 
			
		||||
    await strapi.documents(PRODUCT_UID).delete({ documentId: product.documentId });
 | 
			
		||||
    await strapi.documents(TAG_UID).delete({ documentId: 'GTagS' });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('Repairs duplicates on a non-component unidirectional many-to-many relation', async () => {
 | 
			
		||||
    const { joinTableName, sourceColumn, targetColumn } = getArticleTagsRelationInfo();
 | 
			
		||||
 | 
			
		||||
    // Ensure baseline tag (GTagR) exists and get its versions
 | 
			
		||||
    const tagVersions = await strapi.db.query(TAG_UID).findMany({ where: { documentId: 'GTagR' } });
 | 
			
		||||
    const draftTag = tagVersions.find((t: any) => t.publishedAt === null)!;
 | 
			
		||||
    const publishedTag = tagVersions.find((t: any) => t.publishedAt !== null)!;
 | 
			
		||||
 | 
			
		||||
    // Create a draft article linking to draft tag via documents service
 | 
			
		||||
    const article = await strapi.documents(ARTICLE_UID).create({
 | 
			
		||||
      data: { title: 'Article-1', tags: [{ documentId: 'GTagR', status: 'draft' }] },
 | 
			
		||||
      status: 'draft',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Load the join row for this article->draftTag
 | 
			
		||||
    const initial = await strapi.db
 | 
			
		||||
      .connection(joinTableName)
 | 
			
		||||
      .select('*')
 | 
			
		||||
      .where(targetColumn, draftTag.id)
 | 
			
		||||
      .first();
 | 
			
		||||
    expect(initial).toBeDefined();
 | 
			
		||||
    const sourceId = initial[sourceColumn];
 | 
			
		||||
 | 
			
		||||
    // Insert unintended duplicate pointing to the published tag
 | 
			
		||||
    const dup = { ...initial } as any;
 | 
			
		||||
    delete dup.id;
 | 
			
		||||
    dup[targetColumn] = publishedTag.id;
 | 
			
		||||
    await strapi.db.connection(joinTableName).insert(dup);
 | 
			
		||||
 | 
			
		||||
    // Sanity check
 | 
			
		||||
    let rows = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
    expect(rows.length).toBe(2);
 | 
			
		||||
 | 
			
		||||
    const removed = await strapi.db.repair.processUnidirectionalJoinTables(testCleaner);
 | 
			
		||||
    expect(removed).toBeGreaterThanOrEqual(1);
 | 
			
		||||
 | 
			
		||||
    // Expect only the draft relation to remain
 | 
			
		||||
    rows = await selectBySource(joinTableName, sourceColumn, sourceId);
 | 
			
		||||
    expect(rows.length).toBe(1);
 | 
			
		||||
    expect(rows[0][targetColumn]).toBe(draftTag.id);
 | 
			
		||||
 | 
			
		||||
    // Cleanup article
 | 
			
		||||
    await strapi.documents(ARTICLE_UID).delete({ documentId: article.documentId });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user