Files
strapi/examples/complex/scripts/validate-migration.js

1094 lines
40 KiB
JavaScript

#!/usr/bin/env node
const { createStrapi, compileStrapi } = require('@strapi/strapi');
const path = require('path');
// Expected counts per run (kept small for example seeding in this repo)
const EXPECTED_COUNTS_PER_RUN = {
basic: 5,
basicDp: { published: 3, drafts: 2, total: 5 },
basicDpI18n: { published: 6, drafts: 4, total: 10 },
relation: 5,
relationDp: { published: 5, drafts: 3, total: 8 },
relationDpI18n: { published: 10, drafts: 6, total: 16 },
};
const MEDIA_PER_RUN = 10;
function parseCliArgs(argv) {
const opts = { multiplier: 1, expectInvalidFk: true };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--multiplier' && argv[i + 1] != null) {
opts.multiplier = Number(argv[i + 1]);
i += 1;
continue;
}
if (arg?.startsWith('--multiplier=')) {
opts.multiplier = Number(arg.split('=')[1]);
continue;
}
if (!Number.isNaN(Number(arg))) {
opts.multiplier = Number(arg);
}
}
const envMultiplier = process.env.MIGRATION_MULTIPLIER ?? process.env.SEED_MULTIPLIER;
if (!Number.isNaN(Number(envMultiplier))) {
opts.multiplier = Number(envMultiplier);
}
if (!Number.isFinite(opts.multiplier) || opts.multiplier <= 0) {
opts.multiplier = 1;
}
return opts;
}
function getExpectedCounts(multiplier = 1) {
const m = Number(multiplier) || 1;
return {
basic: EXPECTED_COUNTS_PER_RUN.basic * m,
basicDp: {
published: EXPECTED_COUNTS_PER_RUN.basicDp.published * m,
drafts: EXPECTED_COUNTS_PER_RUN.basicDp.drafts * m,
total: EXPECTED_COUNTS_PER_RUN.basicDp.total * m,
},
basicDpI18n: {
published: EXPECTED_COUNTS_PER_RUN.basicDpI18n.published * m,
drafts: EXPECTED_COUNTS_PER_RUN.basicDpI18n.drafts * m,
total: EXPECTED_COUNTS_PER_RUN.basicDpI18n.total * m,
},
relation: EXPECTED_COUNTS_PER_RUN.relation * m,
relationDp: {
published: EXPECTED_COUNTS_PER_RUN.relationDp.published * m,
drafts: EXPECTED_COUNTS_PER_RUN.relationDp.drafts * m,
total: EXPECTED_COUNTS_PER_RUN.relationDp.total * m,
},
relationDpI18n: {
published: EXPECTED_COUNTS_PER_RUN.relationDpI18n.published * m,
drafts: EXPECTED_COUNTS_PER_RUN.relationDpI18n.drafts * m,
total: EXPECTED_COUNTS_PER_RUN.relationDpI18n.total * m,
},
media: MEDIA_PER_RUN * m,
};
}
function parseCountResult(countResult) {
if (!countResult) return 0;
if (typeof countResult === 'number') return countResult;
if (countResult.count !== undefined) return Number(countResult.count) || 0;
if (countResult['count(*)'] !== undefined) return Number(countResult['count(*)']) || 0;
const first = Object.values(countResult)[0];
return Number(first) || 0;
}
async function validateCounts(strapi, expected) {
const errors = [];
const checks = [];
// Helper to count using db.query
async function countFor(uid) {
const res = await strapi.db.query(uid).count();
return parseCountResult(res[0] || res);
}
async function countPublishedDrafts(uid) {
const publishedRes = await strapi.db.query(uid).count({
where: { publishedAt: { $notNull: true } },
});
const draftRes = await strapi.db.query(uid).count({
where: { publishedAt: { $null: true } },
});
return {
published: parseCountResult(publishedRes[0] || publishedRes),
drafts: parseCountResult(draftRes[0] || draftRes),
};
}
// basic
const basicCount = await countFor('api::basic.basic');
checks.push({ type: 'basic', actual: basicCount, expected: expected.basic });
if (basicCount !== expected.basic)
errors.push(`basic: expected ${expected.basic}, got ${basicCount}`);
// basic-dp
const basicDpCounts = await countPublishedDrafts('api::basic-dp.basic-dp');
const basicDpExpectedDrafts = expected.basicDp.drafts + expected.basicDp.published;
const basicDpExpectedTotal = expected.basicDp.published * 2 + expected.basicDp.drafts;
checks.push({
type: 'basic-dp (published)',
actual: basicDpCounts.published,
expected: expected.basicDp.published,
});
checks.push({
type: 'basic-dp (drafts)',
actual: basicDpCounts.drafts,
expected: basicDpExpectedDrafts,
});
checks.push({
type: 'basic-dp (total)',
actual: basicDpCounts.published + basicDpCounts.drafts,
expected: basicDpExpectedTotal,
});
if (basicDpCounts.published !== expected.basicDp.published)
errors.push(
`basic-dp published: expected ${expected.basicDp.published}, got ${basicDpCounts.published}`
);
if (basicDpCounts.drafts !== basicDpExpectedDrafts)
errors.push(`basic-dp drafts: expected ${basicDpExpectedDrafts}, got ${basicDpCounts.drafts}`);
// basic-dp-i18n
const basicDpI18nCounts = await countPublishedDrafts('api::basic-dp-i18n.basic-dp-i18n');
const basicDpI18nExpectedDrafts = expected.basicDpI18n.drafts + expected.basicDpI18n.published;
const basicDpI18nExpectedTotal = expected.basicDpI18n.published * 2 + expected.basicDpI18n.drafts;
checks.push({
type: 'basic-dp-i18n (published)',
actual: basicDpI18nCounts.published,
expected: expected.basicDpI18n.published,
});
checks.push({
type: 'basic-dp-i18n (drafts)',
actual: basicDpI18nCounts.drafts,
expected: basicDpI18nExpectedDrafts,
});
checks.push({
type: 'basic-dp-i18n (total)',
actual: basicDpI18nCounts.published + basicDpI18nCounts.drafts,
expected: basicDpI18nExpectedTotal,
});
if (basicDpI18nCounts.published !== expected.basicDpI18n.published)
errors.push(
`basic-dp-i18n published: expected ${expected.basicDpI18n.published}, got ${basicDpI18nCounts.published}`
);
if (basicDpI18nCounts.drafts !== basicDpI18nExpectedDrafts)
errors.push(
`basic-dp-i18n drafts: expected ${basicDpI18nExpectedDrafts}, got ${basicDpI18nCounts.drafts}`
);
// relation
const relationCount = await countFor('api::relation.relation');
checks.push({ type: 'relation', actual: relationCount, expected: expected.relation });
if (relationCount !== expected.relation)
errors.push(`relation: expected ${expected.relation}, got ${relationCount}`);
// relation-dp
const relationDpCounts = await countPublishedDrafts('api::relation-dp.relation-dp');
const relationDpExpectedDrafts = expected.relationDp.drafts + expected.relationDp.published;
const relationDpExpectedTotal = expected.relationDp.published * 2 + expected.relationDp.drafts;
checks.push({
type: 'relation-dp (published)',
actual: relationDpCounts.published,
expected: expected.relationDp.published,
});
checks.push({
type: 'relation-dp (drafts)',
actual: relationDpCounts.drafts,
expected: relationDpExpectedDrafts,
});
checks.push({
type: 'relation-dp (total)',
actual: relationDpCounts.published + relationDpCounts.drafts,
expected: relationDpExpectedTotal,
});
if (relationDpCounts.published !== expected.relationDp.published)
errors.push(
`relation-dp published: expected ${expected.relationDp.published}, got ${relationDpCounts.published}`
);
if (relationDpCounts.drafts !== relationDpExpectedDrafts)
errors.push(
`relation-dp drafts: expected ${relationDpExpectedDrafts}, got ${relationDpCounts.drafts}`
);
// relation-dp-i18n
const relationDpI18nCounts = await countPublishedDrafts('api::relation-dp-i18n.relation-dp-i18n');
const relationDpI18nExpectedDrafts =
expected.relationDpI18n.drafts + expected.relationDpI18n.published;
const relationDpI18nExpectedTotal =
expected.relationDpI18n.published * 2 + expected.relationDpI18n.drafts;
checks.push({
type: 'relation-dp-i18n (published)',
actual: relationDpI18nCounts.published,
expected: expected.relationDpI18n.published,
});
checks.push({
type: 'relation-dp-i18n (drafts)',
actual: relationDpI18nCounts.drafts,
expected: relationDpI18nExpectedDrafts,
});
checks.push({
type: 'relation-dp-i18n (total)',
actual: relationDpI18nCounts.published + relationDpI18nCounts.drafts,
expected: relationDpI18nExpectedTotal,
});
if (relationDpI18nCounts.published !== expected.relationDpI18n.published)
errors.push(
`relation-dp-i18n published: expected ${expected.relationDpI18n.published}, got ${relationDpI18nCounts.published}`
);
if (relationDpI18nCounts.drafts !== relationDpI18nExpectedDrafts)
errors.push(
`relation-dp-i18n drafts: expected ${relationDpI18nExpectedDrafts}, got ${relationDpI18nCounts.drafts}`
);
// media files (plugin::upload.file)
const mediaCountRes = await strapi.db.query('plugin::upload.file').count();
const mediaCount = await parseCountResult(mediaCountRes[0] || mediaCountRes);
checks.push({ type: 'media', actual: mediaCount, expected: expected.media });
if (mediaCount < expected.media)
errors.push(`media: expected >= ${expected.media}, got ${mediaCount}`);
return { errors, checks };
}
function getEntityIdentifier(entity) {
if (!entity) return null;
if (entity.documentId) return `${entity.documentId}::${entity.locale || ''}`;
if (entity.id != null) return `id:${entity.id}`;
return null;
}
function getEntityIdentifierArray(arr) {
if (!Array.isArray(arr)) return [];
return arr.map(getEntityIdentifier).filter(Boolean);
}
async function validateDocumentStructure(strapi, expected) {
const errors = [];
// basic-dp: ensure published entries have a draft counterpart
// Use the Document Service API shorthand: strapi.documents(uid).findMany(...)
const all = await strapi.documents('api::basic-dp.basic-dp').findMany({ populate: '*' });
const byDoc = new Map();
for (const e of all) {
if (!e.documentId) {
errors.push(`basic-dp id=${e.id}: missing documentId`);
continue;
}
const doc = byDoc.get(e.documentId) || { draft: null, published: null };
if (e.publishedAt) doc.published = e;
else doc.draft = e;
byDoc.set(e.documentId, doc);
}
for (const [docId, pair] of byDoc.entries()) {
if (pair.published && !pair.draft)
errors.push(`basic-dp documentId ${docId}: published without draft`);
}
// basic-dp-i18n: per-locale check
const allI18n = await strapi
.documents('api::basic-dp-i18n.basic-dp-i18n')
.findMany({ populate: '*', locale: 'all' });
const mapI18n = new Map();
for (const e of allI18n) {
if (!e.documentId) {
errors.push(`basic-dp-i18n id=${e.id}: missing documentId`);
continue;
}
const key = `${e.documentId}::${e.locale || ''}`;
const cur = mapI18n.get(key) || { draft: null, published: null };
if (e.publishedAt) cur.published = e;
else cur.draft = e;
mapI18n.set(key, cur);
}
for (const [k, v] of mapI18n.entries()) {
if (v.published && !v.draft) errors.push(`basic-dp-i18n ${k}: published without draft`);
}
// relation-dp checks (draft/publish pairing)
const relDpAll = await strapi
.documents('api::relation-dp.relation-dp')
.findMany({ populate: '*' });
const relByDoc = new Map();
for (const e of relDpAll) {
if (!e.documentId) {
errors.push(`relation-dp id=${e.id}: missing documentId`);
continue;
}
const doc = relByDoc.get(e.documentId) || { draft: null, published: null };
if (e.publishedAt) doc.published = e;
else doc.draft = e;
relByDoc.set(e.documentId, doc);
}
for (const [docId, pair] of relByDoc.entries()) {
if (pair.published && !pair.draft)
errors.push(`relation-dp documentId ${docId}: published without draft`);
}
// relation-dp-i18n: per-locale
const relDpI18nAll = await strapi
.documents('api::relation-dp-i18n.relation-dp-i18n')
.findMany({ populate: '*', locale: 'all' });
const relI18nMap = new Map();
for (const e of relDpI18nAll) {
if (!e.documentId) {
errors.push(`relation-dp-i18n id=${e.id}: missing documentId`);
continue;
}
const key = `${e.documentId}::${e.locale || ''}`;
const cur = relI18nMap.get(key) || { draft: null, published: null };
if (e.publishedAt) cur.published = e;
else cur.draft = e;
relI18nMap.set(key, cur);
}
for (const [k, v] of relI18nMap.entries()) {
if (v.published && !v.draft) errors.push(`relation-dp-i18n ${k}: published without draft`);
}
return { errors };
}
async function validateRelationsPresence(strapi) {
const errors = [];
// For relation entries, ensure references exist (simple presence checks)
const rels = await strapi.documents('api::relation.relation').findMany({ populate: '*' });
for (const e of rels) {
if (e.oneToOneBasic && !e.oneToOneBasic.id)
errors.push(`relation.id=${e.id} oneToOneBasic missing id`);
if (e.manyToOneBasic && !e.manyToOneBasic.id)
errors.push(`relation.id=${e.id} manyToOneBasic missing id`);
if (Array.isArray(e.oneToManyBasics) && e.oneToManyBasics.some((x) => !x || !x.id))
errors.push(`relation.id=${e.id} oneToManyBasics contains missing refs`);
if (Array.isArray(e.manyToManyBasics) && e.manyToManyBasics.some((x) => !x || !x.id))
errors.push(`relation.id=${e.id} manyToManyBasics contains missing refs`);
if (e.morph_to_one && !e.morph_to_one.id)
errors.push(`relation.id=${e.id} morph_to_one missing id`);
if (Array.isArray(e.morph_to_many) && e.morph_to_many.some((x) => !x || !x.id))
errors.push(`relation.id=${e.id} morph_to_many contains missing refs`);
}
// relation-dp: verify relation fields present for both published/draft (basic presence)
const relDp = await strapi.documents('api::relation-dp.relation-dp').findMany({ populate: '*' });
for (const e of relDp) {
if (e.oneToOneBasic && !e.oneToOneBasic.id)
errors.push(`relation-dp.id=${e.id} oneToOneBasic missing id`);
}
return { errors };
}
function buildPopulateFromAttributes(attributes) {
const populate = {};
for (const [name, attr] of Object.entries(attributes || {})) {
if (!attr) continue;
if (attr.type === 'relation' || attr.type === 'media') {
populate[name] = true;
} else if (attr.type === 'component' || attr.type === 'dynamiczone') {
populate[name] = { populate: '*' };
}
}
return Object.keys(populate).length > 0 ? populate : undefined;
}
function isI18nContentType(contentType) {
return Boolean(contentType?.pluginOptions?.i18n?.localized);
}
function validateRelationValue(value, path) {
if (!value) return null;
if (Array.isArray(value)) {
for (const item of value) {
if (!item || item.id == null) {
return `${path}: missing related id`;
}
}
return null;
}
if (value.id == null) {
return `${path}: missing related id`;
}
return null;
}
function validateComponentValue(component, componentUid, path, strapi) {
const errors = [];
if (!component || !componentUid) return errors;
const schema = strapi.components[componentUid];
if (!schema) {
errors.push(`${path}: unknown component ${componentUid}`);
return errors;
}
for (const [name, attr] of Object.entries(schema.attributes || {})) {
const value = component[name];
if (attr.type === 'relation' || attr.type === 'media') {
const err = validateRelationValue(value, `${path}.${name}`);
if (err) errors.push(err);
} else if (attr.type === 'component') {
if (Array.isArray(value)) {
value.forEach((item, idx) => {
errors.push(
...validateComponentValue(item, attr.component, `${path}.${name}[${idx}]`, strapi)
);
});
} else if (value) {
errors.push(...validateComponentValue(value, attr.component, `${path}.${name}`, strapi));
}
} else if (attr.type === 'dynamiczone') {
if (Array.isArray(value)) {
value.forEach((item, idx) => {
const compUid = item?.__component;
errors.push(...validateComponentValue(item, compUid, `${path}.${name}[${idx}]`, strapi));
});
}
}
}
return errors;
}
async function validateEntityGraph(strapi) {
const errors = [];
const contentTypes = Object.values(strapi.contentTypes).filter((ct) =>
ct.uid.startsWith('api::')
);
for (const contentType of contentTypes) {
const populate = buildPopulateFromAttributes(contentType.attributes);
const locale = isI18nContentType(contentType) ? 'all' : undefined;
const entries = await strapi
.documents(contentType.uid)
.findMany({ populate, ...(locale ? { locale } : {}) });
for (const entry of entries) {
for (const [name, attr] of Object.entries(contentType.attributes || {})) {
const value = entry[name];
if (attr.type === 'relation' || attr.type === 'media') {
const err = validateRelationValue(value, `${contentType.uid}.${name}#${entry.id}`);
if (err) errors.push(err);
} else if (attr.type === 'component') {
if (Array.isArray(value)) {
value.forEach((item, idx) => {
errors.push(
...validateComponentValue(
item,
attr.component,
`${contentType.uid}.${name}#${entry.id}[${idx}]`,
strapi
)
);
});
} else if (value) {
errors.push(
...validateComponentValue(
value,
attr.component,
`${contentType.uid}.${name}#${entry.id}`,
strapi
)
);
}
} else if (attr.type === 'dynamiczone') {
if (Array.isArray(value)) {
value.forEach((item, idx) => {
const compUid = item?.__component;
if (!compUid) {
errors.push(`${contentType.uid}.${name}#${entry.id}[${idx}]: missing __component`);
return;
}
errors.push(
...validateComponentValue(
item,
compUid,
`${contentType.uid}.${name}#${entry.id}[${idx}]`,
strapi
)
);
});
}
}
}
}
}
return { errors };
}
function summarizeRelations(entry, relationFields) {
const summary = {};
for (const field of relationFields) {
const value = entry[field];
if (Array.isArray(value)) {
summary[field] = getEntityIdentifierArray(value).sort();
} else {
summary[field] = getEntityIdentifier(value);
}
}
return summary;
}
function areRelationSummariesEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
/**
* Collect all media (file) ids from an entry recursively (direct media + component/dz media).
*/
function collectMediaIds(value, path = '') {
const ids = [];
if (!value) return ids;
if (Array.isArray(value)) {
value.forEach((item, idx) => {
if (item && (item.id != null || item.documentId != null)) {
ids.push(item.id ?? item.documentId);
}
if (item && typeof item === 'object' && !item.id && !item.documentId) {
ids.push(...collectMediaIds(item, `${path}[${idx}]`));
}
});
return ids;
}
if (value.id != null || value.documentId != null) {
ids.push(value.id ?? value.documentId);
return ids;
}
if (typeof value === 'object') {
for (const v of Object.values(value)) {
ids.push(...collectMediaIds(v, path));
}
}
return ids;
}
/**
* Bug 1: Ensure draft has same media as published (catches missing files_related_morphs for drafts).
*/
async function validateMediaParityForDp(strapi) {
const errors = [];
const dpUids = [
'api::basic-dp.basic-dp',
'api::relation-dp.relation-dp',
'api::basic-dp-i18n.basic-dp-i18n',
'api::relation-dp-i18n.relation-dp-i18n',
];
for (const uid of dpUids) {
const contentType = strapi.contentTypes[uid];
if (!contentType) continue;
const populate = buildPopulateFromAttributes(contentType.attributes);
const locale = isI18nContentType(contentType) ? 'all' : undefined;
const entries = await strapi
.documents(uid)
.findMany({ populate, ...(locale ? { locale } : {}) });
const byDoc = new Map();
for (const entry of entries) {
const key = locale ? `${entry.documentId}::${entry.locale || ''}` : entry.documentId;
if (!key) continue;
const bucket = byDoc.get(key) || { draft: null, published: null };
if (entry.publishedAt) bucket.published = entry;
else bucket.draft = entry;
byDoc.set(key, bucket);
}
for (const [key, pair] of byDoc.entries()) {
if (!pair.published || !pair.draft) continue;
const pubIds = collectMediaIds(pair.published).sort();
const draftIds = collectMediaIds(pair.draft).sort();
if (JSON.stringify(pubIds) !== JSON.stringify(draftIds)) {
errors.push(
`${uid} ${key}: media parity failed (published: ${pubIds.length} media, draft: ${draftIds.length} media)`
);
}
}
}
return { errors };
}
/**
* Bug 2: Ensure nested component relations (e.g. reference-list.references[].article) resolve on both draft and published.
*/
async function validateNestedComponentRelationParity(strapi) {
const errors = [];
// Dynamic zones (sections) only allow populate: '*' — no nested field targeting
const populate = { sections: { populate: '*' }, header: { populate: '*' } };
const entries = await strapi.documents('api::relation-dp.relation-dp').findMany({ populate });
const byDoc = new Map();
for (const entry of entries) {
if (!entry.documentId) continue;
const bucket = byDoc.get(entry.documentId) || { draft: null, published: null };
if (entry.publishedAt) bucket.published = entry;
else bucket.draft = entry;
byDoc.set(entry.documentId, bucket);
}
for (const [docId, pair] of byDoc.entries()) {
if (!pair.published || !pair.draft) continue;
const sectionsPub = pair.published.sections || [];
const sectionsDraft = pair.draft.sections || [];
for (let i = 0; i < Math.max(sectionsPub.length, sectionsDraft.length); i++) {
const sp = sectionsPub[i];
const sd = sectionsDraft[i];
if (
sp?.__component === 'shared.reference-list' ||
sd?.__component === 'shared.reference-list'
) {
const refsPub = sp?.references || [];
const refsDraft = sd?.references || [];
for (let j = 0; j < Math.max(refsPub.length, refsDraft.length); j++) {
const rp = refsPub[j];
const rd = refsDraft[j];
const pubArticle = rp?.article?.id ?? rp?.article?.documentId;
const draftArticle = rd?.article?.id ?? rd?.article?.documentId;
if (pubArticle != null && draftArticle == null) {
errors.push(
`relation-dp documentId ${docId}: nested reference-list.references[${j}].article present on published, missing on draft`
);
}
if (draftArticle != null && pubArticle == null) {
errors.push(
`relation-dp documentId ${docId}: nested reference-list.references[${j}].article present on draft, missing on published`
);
}
}
}
}
}
return { errors };
}
/**
* Optional DB-level verification that the migration fixes are in effect.
* Runs only when all validations pass. Prints evidence without failing the run.
* - Bug 1: Morph rows for media: (1) relation-dp direct (none in this seed), (2) component media (e.g. shared.logo) under relation-dp.
* - Bug 2: Draft and published use distinct nested component IDs for the same document.
*/
async function verifyMigrationFixAtDbLevel(strapi) {
const out = [];
const errors = [];
try {
const conn = strapi.db.connection;
const meta = strapi.db.metadata;
// --- Morph table (Bug 1) ---
const fileMeta = meta.get('plugin::upload.file');
const relatedAttr = fileMeta?.attributes?.related;
const morphJoin = relatedAttr?.joinTable;
if (morphJoin?.morphColumn) {
const morphTable = morphJoin.name;
const relatedIdCol = morphJoin.morphColumn.idColumn.name;
const relatedTypeCol = morphJoin.morphColumn.typeColumn.name;
const relationDpMeta = meta.get('api::relation-dp.relation-dp');
const relTable = relationDpMeta.tableName;
const rows = await conn(relTable).select('id', 'document_id', 'published_at');
const publishedIds = rows.filter((r) => r.published_at != null).map((r) => r.id);
const draftIds = rows.filter((r) => r.published_at == null).map((r) => r.id);
const relType = 'api::relation-dp.relation-dp';
const countPub =
publishedIds.length === 0
? 0
: Number(
(
await conn(morphTable)
.where(relatedTypeCol, relType)
.whereIn(relatedIdCol, publishedIds)
.count('* as c')
.first()
)?.c ?? 0
);
const countDraft =
draftIds.length === 0
? 0
: Number(
(
await conn(morphTable)
.where(relatedTypeCol, relType)
.whereIn(relatedIdCol, draftIds)
.count('* as c')
.first()
)?.c ?? 0
);
const morphNote =
countPub === 0 && countDraft === 0
? ' (relation-dp has no direct media field in this schema)'
: countPub > 0 && countDraft > 0
? ' (draft has media morph rows; fix verified)'
: ' (without fix draft would be 0 when published have media)';
out.push(
` Morph (relation-dp direct): ${countPub} published, ${countDraft} draft${morphNote}.`
);
}
// --- Morph for component media (Bug 1): shared.logo under relation-dp (header.logo). This actually exercises the migration's component morph copy.
if (morphJoin?.morphColumn) {
const morphTable = morphJoin.name;
const relatedIdCol = morphJoin.morphColumn.idColumn.name;
const relatedTypeCol = morphJoin.morphColumn.typeColumn.name;
const relationDpMeta = meta.get('api::relation-dp.relation-dp');
const relTable = relationDpMeta.tableName;
const rows = await conn(relTable).select('id', 'published_at');
const publishedIds = rows.filter((r) => r.published_at != null).map((r) => r.id);
const draftIds = rows.filter((r) => r.published_at == null).map((r) => r.id);
const rdCmps = meta.get('api::relation-dp.relation-dp').attributes?.sections?.joinTable;
const rdCmpsTable = rdCmps?.name;
const entityIdCol = rdCmps?.joinColumn?.name;
const cmpIdCol = rdCmps?.morphColumn?.idColumn?.name;
const cmpTypeCol = rdCmps?.morphColumn?.typeColumn?.name;
if (!rdCmpsTable || !entityIdCol || !cmpIdCol || !cmpTypeCol) return out;
// Header component IDs under relation_dp (first-level sections include header)
const headerType = 'shared.header';
const headerRows = await conn(rdCmpsTable)
.where(cmpTypeCol, headerType)
.whereIn(entityIdCol, [...publishedIds, ...draftIds])
.select(entityIdCol, cmpIdCol);
const headerIdsByRelDp = new Map();
for (const r of headerRows) {
const eid = r[entityIdCol];
if (!headerIdsByRelDp.has(eid)) headerIdsByRelDp.set(eid, []);
headerIdsByRelDp.get(eid).push(r[cmpIdCol]);
}
// Logo component IDs: from header join table (shared.header has headerlogo -> shared.logo).
// Component->component uses joinColumn (parent entity_id) and inverseJoinColumn (child cmp_id), not morphColumn.
let headerLogoJoinTable;
let headerEntityCol;
let logoCmpCol;
for (const uid of ['shared.header', 'component::shared.header']) {
try {
const headerMeta = meta.get(uid);
const headerLogoAttr = headerMeta?.attributes?.headerlogo;
const jt = headerLogoAttr?.joinTable;
if (jt?.joinColumn?.name && jt?.inverseJoinColumn?.name) {
headerLogoJoinTable = jt.name;
headerEntityCol = jt.joinColumn.name;
logoCmpCol = jt.inverseJoinColumn.name;
break;
}
} catch (_) {}
}
if (!headerLogoJoinTable) {
out.push(' Morph (shared.logo under relation-dp): skipped (no header logo join table).');
} else {
const allHeaderIds = [...new Set(headerRows.map((r) => r[cmpIdCol]))];
const logoRows = await conn(headerLogoJoinTable)
.whereIn(headerEntityCol, allHeaderIds)
.select(headerEntityCol, logoCmpCol);
const logoIdByHeaderId = new Map();
for (const r of logoRows) {
logoIdByHeaderId.set(r[headerEntityCol], r[logoCmpCol]);
}
const publishedLogoIds = [];
const draftLogoIds = [];
for (const id of publishedIds) {
for (const hid of headerIdsByRelDp.get(id) || []) {
const lid = logoIdByHeaderId.get(hid);
if (lid != null) publishedLogoIds.push(lid);
}
}
for (const id of draftIds) {
for (const hid of headerIdsByRelDp.get(id) || []) {
const lid = logoIdByHeaderId.get(hid);
if (lid != null) draftLogoIds.push(lid);
}
}
const logoType = 'shared.logo';
const countPubLogo =
publishedLogoIds.length === 0
? 0
: Number(
(
await conn(morphTable)
.where(relatedTypeCol, logoType)
.whereIn(relatedIdCol, publishedLogoIds)
.count('* as c')
.first()
)?.c ?? 0
);
const countDraftLogo =
draftLogoIds.length === 0
? 0
: Number(
(
await conn(morphTable)
.where(relatedTypeCol, logoType)
.whereIn(relatedIdCol, draftLogoIds)
.count('* as c')
.first()
)?.c ?? 0
);
const logoNote =
countPubLogo > 0 && countDraftLogo === 0
? ' (BUG: draft would have 0 without migration fix)'
: countPubLogo > 0 && countDraftLogo > 0
? ' (draft clones have media morph rows; fix verified)'
: ' (no logo media on relation-dp in this seed)';
out.push(
` Morph (shared.logo under relation-dp): ${countPubLogo} published, ${countDraftLogo} draft${logoNote}.`
);
}
}
// --- Source-side morph (universal fix): relation-dp.morphTargets (morphMany) ---
const relationDpMeta = meta.get('api::relation-dp.relation-dp');
const morphTargetsAttr = relationDpMeta?.attributes?.morphTargets;
const morphTargetsJoin = morphTargetsAttr?.joinTable;
if (morphTargetsJoin?.morphColumn?.idColumn && morphTargetsJoin?.joinColumn?.name) {
const morphTable = morphTargetsJoin.name;
const sourceCol = morphTargetsJoin.joinColumn.name;
const relTable = relationDpMeta.tableName;
const relRows = await conn(relTable).select('id', 'published_at');
const pubIds = relRows.filter((r) => r.published_at != null).map((r) => r.id);
const draftIds = relRows.filter((r) => r.published_at == null).map((r) => r.id);
const countPub =
pubIds.length === 0
? 0
: Number(
(await conn(morphTable).whereIn(sourceCol, pubIds).count('* as c').first())?.c ?? 0
);
const countDraft =
draftIds.length === 0
? 0
: Number(
(await conn(morphTable).whereIn(sourceCol, draftIds).count('* as c').first())?.c ?? 0
);
const note =
countPub > 0 && countDraft > 0
? ' (source-side morph copied to drafts; universal fix verified)'
: countPub > 0 && countDraft === 0
? ' (BUG: draft would have 0 without fix)'
: '';
out.push(
` Morph (relation-dp morphTargets, source-side): ${countPub} published, ${countDraft} draft${note}.`
);
}
// --- Nested component IDs (Bug 2) ---
const sectionsAttr = relationDpMeta?.attributes?.sections;
const dzJoin = sectionsAttr?.joinTable;
if (
dzJoin?.joinColumn?.name &&
dzJoin?.morphColumn?.idColumn?.name &&
dzJoin?.morphColumn?.typeColumn?.name
) {
const joinTableName = dzJoin.name;
const entityIdCol = dzJoin.joinColumn.name;
const componentIdCol = dzJoin.morphColumn.idColumn.name;
const componentTypeCol = dzJoin.morphColumn.typeColumn.name;
const relTable = relationDpMeta.tableName;
const relRows = await conn(relTable).select('id', 'document_id', 'published_at');
const byDoc = new Map();
for (const r of relRows) {
const docId = r.document_id;
if (!docId) continue;
const bucket = byDoc.get(docId) || { published: null, draft: null };
if (r.published_at != null) bucket.published = r.id;
else bucket.draft = r.id;
byDoc.set(docId, bucket);
}
let docsWithRefList = 0;
let docsWithDistinctCmpIds = 0;
let docsWithDz = 0;
let docsWithOverlap = 0;
const refListType = 'shared.reference-list';
for (const [docId, pair] of byDoc.entries()) {
if (!pair.published || !pair.draft) continue;
const dzRows = await conn(joinTableName)
.whereIn(entityIdCol, [pair.published, pair.draft])
.select(entityIdCol, componentIdCol, componentTypeCol);
const pubDz = new Set(
dzRows
.filter((row) => row[entityIdCol] === pair.published)
.map((row) => row[componentIdCol])
);
const draftDz = new Set(
dzRows.filter((row) => row[entityIdCol] === pair.draft).map((row) => row[componentIdCol])
);
if (pubDz.size === 0 && draftDz.size === 0) continue;
docsWithDz += 1;
const overlaps = [...pubDz].filter((id) => draftDz.has(id));
if (overlaps.length > 0) {
docsWithOverlap += 1;
errors.push(
`relation-dp documentId ${docId}: ${overlaps.length} dynamic-zone component id(s) shared between published and draft`
);
}
const refRows = dzRows.filter((row) => row[componentTypeCol] === refListType);
const pubCmps = new Set(
refRows
.filter((row) => row[entityIdCol] === pair.published)
.map((row) => row[componentIdCol])
);
const draftCmps = new Set(
refRows.filter((row) => row[entityIdCol] === pair.draft).map((row) => row[componentIdCol])
);
if (pubCmps.size === 0 && draftCmps.size === 0) continue;
docsWithRefList += 1;
const disjoint = [...pubCmps].every((id) => !draftCmps.has(id));
if (disjoint && pubCmps.size > 0) docsWithDistinctCmpIds += 1;
}
out.push(
` Nested components: ${docsWithRefList} document(s) with reference-list; ${docsWithDistinctCmpIds} have distinct draft vs published component IDs.`
);
out.push(
` Dynamic zone components: ${docsWithDz} document(s); ${docsWithOverlap} have overlapping component IDs.`
);
}
} catch (e) {
out.push(` Verification skipped (${e.message}).`);
}
return { lines: out, errors };
}
async function validateRelationParityForDp(strapi, uid) {
const errors = [];
const contentType = strapi.contentTypes[uid];
if (!contentType) return { errors };
const relationFields = Object.entries(contentType.attributes || {})
.filter(([, attr]) => attr.type === 'relation')
.map(([name]) => name);
if (relationFields.length === 0) return { errors };
const populate = buildPopulateFromAttributes(contentType.attributes);
const locale = isI18nContentType(contentType) ? 'all' : undefined;
const entries = await strapi.documents(uid).findMany({ populate, ...(locale ? { locale } : {}) });
const byDoc = new Map();
for (const entry of entries) {
if (!entry.documentId) continue;
const key = `${entry.documentId}::${entry.locale || ''}`;
const bucket = byDoc.get(key) || { draft: null, published: null };
if (entry.publishedAt) bucket.published = entry;
else bucket.draft = entry;
byDoc.set(key, bucket);
}
for (const [key, pair] of byDoc.entries()) {
if (!pair.published || !pair.draft) continue;
const publishedSummary = summarizeRelations(pair.published, relationFields);
const draftSummary = summarizeRelations(pair.draft, relationFields);
if (!areRelationSummariesEqual(publishedSummary, draftSummary)) {
errors.push(`${uid} ${key}: draft/published relations diverge`);
}
}
return { errors };
}
async function run() {
const argv = process.argv.slice(2);
const opts = parseCliArgs(argv);
const expected = getExpectedCounts(opts.multiplier);
console.log('🔍 Starting document-service validator (this will boot Strapi programmatically)...');
console.log(` multiplier: ${opts.multiplier}`);
const appContext = await compileStrapi();
const strapi = await createStrapi(appContext).load();
strapi.log.level = 'error';
// Log which DB we use (same config as config/database.ts + .env when run from examples/complex)
const dbConfig = strapi.config.get('database');
const conn = dbConfig?.connection?.connection || {};
const client = dbConfig?.connection?.client || '?';
const dbDesc = conn.connectionString
? `${client} (from DATABASE_URL)`
: `${client} ${conn.host || 'localhost'}:${conn.port ?? (client === 'postgres' ? 5432 : 3306)}/${conn.database || 'strapi'}`;
console.log(` database: ${dbDesc}`);
try {
const results = { errors: [], checks: [], sections: [] };
const countsResult = await validateCounts(strapi, expected);
results.errors.push(...countsResult.errors);
results.checks.push(...countsResult.checks);
results.sections.push({ name: 'Counts', errors: countsResult.errors });
const docStruct = await validateDocumentStructure(strapi, expected);
results.errors.push(...docStruct.errors);
results.sections.push({ name: 'Draft/publish pairing', errors: docStruct.errors });
const relPresence = await validateRelationsPresence(strapi);
results.errors.push(...relPresence.errors);
results.sections.push({ name: 'Relation targets', errors: relPresence.errors });
const relationParity = await validateRelationParityForDp(
strapi,
'api::relation-dp.relation-dp'
);
results.errors.push(...relationParity.errors);
const relationParityI18n = await validateRelationParityForDp(
strapi,
'api::relation-dp-i18n.relation-dp-i18n'
);
results.errors.push(...relationParityI18n.errors);
results.sections.push({
name: 'DP relation parity',
errors: [...relationParity.errors, ...relationParityI18n.errors],
});
const entityGraph = await validateEntityGraph(strapi);
results.errors.push(...entityGraph.errors);
results.sections.push({ name: 'Components/dynamic zones/media', errors: entityGraph.errors });
const mediaParity = await validateMediaParityForDp(strapi);
results.errors.push(...mediaParity.errors);
results.sections.push({
name: 'Media parity (draft vs published)',
errors: mediaParity.errors,
});
const nestedComponentParity = await validateNestedComponentRelationParity(strapi);
results.errors.push(...nestedComponentParity.errors);
results.sections.push({
name: 'Nested component relation parity',
errors: nestedComponentParity.errors,
});
// Optional DB-level verification (evidence that migration fixes are in effect)
const verification = await verifyMigrationFixAtDbLevel(strapi);
if (verification.errors.length > 0) {
results.errors.push(...verification.errors);
results.sections.push({ name: 'DB-level verification', errors: verification.errors });
} else {
results.sections.push({ name: 'DB-level verification', errors: [] });
}
// Summarize
console.log('\n✅ Validation summary:');
if (results.errors.length === 0) {
console.log(' All checks passed (no errors)');
} else {
console.log(` Found ${results.errors.length} error(s):`);
for (const e of results.errors.slice(0, 50)) console.log(` - ${e}`);
if (results.errors.length > 50) console.log(` ...and ${results.errors.length - 50} more`);
}
// Print detailed checks
console.log('\n📊 Count checks:');
for (const c of results.checks) {
console.log(` - ${c.type}: actual=${c.actual} expected=${c.expected}`);
}
// Print per-section status
console.log('\n🧪 Validation sections:');
for (const section of results.sections) {
const status = section.errors.length === 0 ? 'ok' : `errors=${section.errors.length}`;
console.log(` - ${section.name}: ${status}`);
}
if (verification.lines.length > 0) {
console.log('\n🔬 DB-level verification (migration fix evidence):');
for (const line of verification.lines) console.log(line);
}
process.exit(results.errors.length === 0 ? 0 : 2);
} catch (err) {
console.error('Validator error:', err);
process.exit(1);
} finally {
try {
await strapi.destroy();
} catch (_) {}
}
}
// Run if invoked directly
if (require.main === module) {
run();
}