diff --git a/packages/core/core/src/Strapi.ts b/packages/core/core/src/Strapi.ts index 5a430a9ed6..50c3cf1428 100644 --- a/packages/core/core/src/Strapi.ts +++ b/packages/core/core/src/Strapi.ts @@ -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 }); } diff --git a/packages/core/core/src/services/document-service/components.ts b/packages/core/core/src/services/document-service/components.ts index 947de8202a..da6806615b 100644 --- a/packages/core/core/src/services/document-service/components.ts +++ b/packages/core/core/src/services/document-service/components.ts @@ -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) => { - return ( - (attr.type === 'component' && attr.component === componentSchema.uid) || - (attr.type === 'dynamiczone' && attr.components?.includes(componentSchema.uid)) - ); - }); - }); +const getParentSchemasForComponent = ( + componentSchema: Schema.Component +): Array => { + // 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, componentSchema: Schema.Component, - parentSchemasForComponent: Schema.ContentType[], + parentSchemasForComponent: (Schema.ContentType | Schema.Component)[], trx: any ): Promise => { // 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, }; diff --git a/packages/core/core/src/services/document-service/utils/clean-component-join-table.ts b/packages/core/core/src/services/document-service/utils/clean-component-join-table.ts new file mode 100644 index 0000000000..ca7219e0af --- /dev/null +++ b/packages/core/core/src/services/document-service/utils/clean-component-join-table.ts @@ -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 => { + 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 => { + 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 []; + } +}; diff --git a/packages/core/database/src/repairs/index.ts b/packages/core/database/src/repairs/index.ts index 2cb280b79f..1264d1d094 100644 --- a/packages/core/database/src/repairs/index.ts +++ b/packages/core/database/src/repairs/index.ts @@ -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), }; }; diff --git a/packages/core/database/src/repairs/operations/process-unidirectional-join-tables.ts b/packages/core/database/src/repairs/operations/process-unidirectional-join-tables.ts new file mode 100644 index 0000000000..39843848bd --- /dev/null +++ b/packages/core/database/src/repairs/operations/process-unidirectional-join-tables.ts @@ -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 +): Promise => { + 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): 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'; +}; diff --git a/tests/api/core/strapi/document-service/relations/unidirectional-relations.test.api.ts b/tests/api/core/strapi/document-service/relations/unidirectional-relations.test.api.ts index ffcbb23502..0004d0c342 100644 --- a/tests/api/core/strapi/document-service/relations/unidirectional-relations.test.api.ts +++ b/tests/api/core/strapi/document-service/relations/unidirectional-relations.test.api.ts @@ -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,10 +26,27 @@ 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 = { collectionName: 'components_compo', attributes: { @@ -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); + }); }); diff --git a/tests/api/core/strapi/repairs/unidirectional-join-table-repair.test.api.ts b/tests/api/core/strapi/repairs/unidirectional-join-table-repair.test.api.ts new file mode 100644 index 0000000000..ad83bd6f79 --- /dev/null +++ b/tests/api/core/strapi/repairs/unidirectional-join-table-repair.test.api.ts @@ -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 => { + 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 = {}; + 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 = {}; + 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 = {}; + // 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 = {}; + 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 }); + }); +});