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:
markkaylor 2025-09-23 10:11:48 +02:00 committed by GitHub
parent aac81a07a7
commit 536eed0201
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1240 additions and 17 deletions

View File

@ -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 });
}

View File

@ -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,
};

View File

@ -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 [];
}
};

View File

@ -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),
};
};

View File

@ -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';
};

View File

@ -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);
});
});

View File

@ -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 });
});
});