@strapi/core 5.36.1 → 5.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/core-api/controller/index.d.ts.map +1 -1
  2. package/dist/core-api/controller/index.js +17 -16
  3. package/dist/core-api/controller/index.js.map +1 -1
  4. package/dist/core-api/controller/index.mjs +17 -16
  5. package/dist/core-api/controller/index.mjs.map +1 -1
  6. package/dist/core-api/routes/index.js +15 -2
  7. package/dist/core-api/routes/index.js.map +1 -1
  8. package/dist/core-api/routes/index.mjs +15 -2
  9. package/dist/core-api/routes/index.mjs.map +1 -1
  10. package/dist/core-api/routes/validation/content-type.d.ts +5 -1
  11. package/dist/core-api/routes/validation/content-type.d.ts.map +1 -1
  12. package/dist/core-api/routes/validation/content-type.js +10 -0
  13. package/dist/core-api/routes/validation/content-type.js.map +1 -1
  14. package/dist/core-api/routes/validation/content-type.mjs +10 -0
  15. package/dist/core-api/routes/validation/content-type.mjs.map +1 -1
  16. package/dist/migrations/database/5.0.0-discard-drafts.d.ts.map +1 -1
  17. package/dist/migrations/database/5.0.0-discard-drafts.js +508 -3
  18. package/dist/migrations/database/5.0.0-discard-drafts.js.map +1 -1
  19. package/dist/migrations/database/5.0.0-discard-drafts.mjs +509 -4
  20. package/dist/migrations/database/5.0.0-discard-drafts.mjs.map +1 -1
  21. package/dist/package.json.js +14 -14
  22. package/dist/package.json.mjs +14 -14
  23. package/dist/services/content-api/index.d.ts +6 -3
  24. package/dist/services/content-api/index.d.ts.map +1 -1
  25. package/dist/services/content-api/index.js +165 -3
  26. package/dist/services/content-api/index.js.map +1 -1
  27. package/dist/services/content-api/index.mjs +147 -4
  28. package/dist/services/content-api/index.mjs.map +1 -1
  29. package/dist/services/document-service/draft-and-publish.d.ts +16 -2
  30. package/dist/services/document-service/draft-and-publish.d.ts.map +1 -1
  31. package/dist/services/document-service/draft-and-publish.js +53 -0
  32. package/dist/services/document-service/draft-and-publish.js.map +1 -1
  33. package/dist/services/document-service/draft-and-publish.mjs +53 -2
  34. package/dist/services/document-service/draft-and-publish.mjs.map +1 -1
  35. package/dist/services/document-service/params.d.ts +24 -0
  36. package/dist/services/document-service/params.d.ts.map +1 -1
  37. package/dist/services/document-service/params.js +33 -0
  38. package/dist/services/document-service/params.js.map +1 -1
  39. package/dist/services/document-service/params.mjs +31 -1
  40. package/dist/services/document-service/params.mjs.map +1 -1
  41. package/dist/services/document-service/repository.d.ts.map +1 -1
  42. package/dist/services/document-service/repository.js +165 -4
  43. package/dist/services/document-service/repository.js.map +1 -1
  44. package/dist/services/document-service/repository.mjs +167 -6
  45. package/dist/services/document-service/repository.mjs.map +1 -1
  46. package/dist/services/document-service/transform/query.d.ts.map +1 -1
  47. package/dist/services/document-service/transform/query.js +39 -3
  48. package/dist/services/document-service/transform/query.js.map +1 -1
  49. package/dist/services/document-service/transform/query.mjs +37 -1
  50. package/dist/services/document-service/transform/query.mjs.map +1 -1
  51. package/dist/services/server/register-routes.js +3 -0
  52. package/dist/services/server/register-routes.js.map +1 -1
  53. package/dist/services/server/register-routes.mjs +3 -0
  54. package/dist/services/server/register-routes.mjs.map +1 -1
  55. package/package.json +14 -14
@@ -1,8 +1,33 @@
1
1
  import { createId } from '@paralleldrive/cuid2';
2
2
  import { contentTypes } from '@strapi/utils';
3
3
  import createDebug from 'debug';
4
- import { getComponentJoinTableName, getComponentJoinColumnEntityName, getComponentJoinColumnInverseName, getComponentTypeColumn } from '../../utils/transform-content-types-to-models.mjs';
4
+ import { getComponentJoinTableName, getComponentJoinColumnEntityName, getComponentJoinColumnInverseName, getComponentTypeColumn, getDzJoinTableName } from '../../utils/transform-content-types-to-models.mjs';
5
5
 
6
+ const resolveComponentMetadataUidCandidates = (componentUid)=>{
7
+ if (componentUid.startsWith('component::')) {
8
+ return [
9
+ componentUid,
10
+ componentUid.slice('component::'.length)
11
+ ];
12
+ }
13
+ return [
14
+ componentUid,
15
+ `component::${componentUid}`
16
+ ];
17
+ };
18
+ const getComponentMetadataByUid = (componentUid)=>{
19
+ for (const candidate of resolveComponentMetadataUidCandidates(componentUid)){
20
+ try {
21
+ const meta = strapi.db.metadata.get(candidate);
22
+ if (meta) {
23
+ return meta;
24
+ }
25
+ } catch {
26
+ continue;
27
+ }
28
+ }
29
+ return null;
30
+ };
6
31
  const DEFAULT_PRIMARY_KEY_COLUMN = 'id';
7
32
  /**
8
33
  * Determines the primary-key column name for a schema, handling the various shapes
@@ -138,6 +163,219 @@ const DEFAULT_PRIMARY_KEY_COLUMN = 'id';
138
163
  uid,
139
164
  publishedToDraftMap
140
165
  });
166
+ // (1) Media: one shared table owned by upload plugin; we are the "related" side (related_id).
167
+ // Copy rows so draft entity ids get the same file links as their published pair.
168
+ await copyMediaMorphToDraftsForContentType({
169
+ trx,
170
+ uid,
171
+ publishedToDraftMap
172
+ });
173
+ // (2) Other morph: per-attribute tables where we are the "source" side (join column = our entity id).
174
+ // Copy rows so draft entity ids get the same polymorphic links. Skips upload table by name.
175
+ await copySourceSideMorphRelationsForContentType({
176
+ trx,
177
+ uid,
178
+ publishedToDraftMap
179
+ });
180
+ }
181
+ /**
182
+ * Returns the upload plugin's morph join table info (e.g. files_related_morphs) if present.
183
+ * This is the single table used by all media fields across content types and components.
184
+ */ function getUploadMorphTableInfo() {
185
+ const fileMeta = strapi.db.metadata.get('plugin::upload.file');
186
+ if (!fileMeta?.attributes) {
187
+ return null;
188
+ }
189
+ const relatedAttr = fileMeta.attributes.related;
190
+ const joinTable = relatedAttr?.joinTable;
191
+ const morphColumn = joinTable?.morphColumn ?? relatedAttr?.morphColumn;
192
+ if (!joinTable?.name || !joinTable?.joinColumn?.name || !morphColumn) {
193
+ return null;
194
+ }
195
+ return {
196
+ tableName: joinTable.name,
197
+ joinColumnName: joinTable.joinColumn.name,
198
+ relatedIdColumnName: morphColumn.idColumn.name,
199
+ relatedTypeColumnName: morphColumn.typeColumn.name
200
+ };
201
+ }
202
+ /**
203
+ * Unified morph copy: duplicate rows in a morph join table, rewriting one column from original ids to draft ids.
204
+ * Used for both (1) media: upload table, we are "related" side, filter by related_type; (2) other morph: we are "source" side, no filter.
205
+ * @param columnToRewrite - the column that holds our entity id (related_id in upload table, entity_id in source-side tables).
206
+ * @param filter - optional: only copy rows where filter.column = filter.value (e.g. related_type = uid for media).
207
+ */ async function copyMorphRowsByIdMap({ trx, tableName, columnToRewrite, idMap, filter, contextReason }) {
208
+ if (idMap.size === 0) return;
209
+ const hasTable = await ensureTableExists(trx, tableName);
210
+ if (!hasTable) return;
211
+ const originalIds = Array.from(idMap.keys());
212
+ const chunks = chunkArray(originalIds, getBatchSize(trx, 500));
213
+ for (const chunk of chunks){
214
+ let q = trx(tableName).select('*').whereIn(columnToRewrite, chunk);
215
+ if (filter) {
216
+ q = q.where(filter.column, filter.value);
217
+ }
218
+ const rows = await q;
219
+ if (rows.length === 0) continue;
220
+ const toInsert = [];
221
+ for (const row of rows){
222
+ const originalId = normalizeId(row[columnToRewrite]);
223
+ if (originalId == null) continue;
224
+ const newId = idMap.get(originalId);
225
+ if (newId == null) continue;
226
+ const { id, ...rest } = row;
227
+ toInsert.push({
228
+ ...rest,
229
+ [columnToRewrite]: newId
230
+ });
231
+ }
232
+ if (toInsert.length > 0) {
233
+ await insertRelationsWithDuplicateHandling({
234
+ trx,
235
+ tableName,
236
+ relations: toInsert,
237
+ context: {
238
+ reason: contextReason,
239
+ tableName
240
+ }
241
+ });
242
+ }
243
+ }
244
+ }
245
+ /**
246
+ * Same as copyMorphRowsByIdMap but for (originalId, draftId) pairs when one original can map to multiple drafts (e.g. cloned components).
247
+ */ async function copyMorphRowsByPairs({ trx, tableName, columnToRewrite, pairs, filter, contextReason }) {
248
+ if (pairs.length === 0) return;
249
+ const hasTable = await ensureTableExists(trx, tableName);
250
+ if (!hasTable) return;
251
+ const originalIds = [
252
+ ...new Set(pairs.map((p)=>p.originalId))
253
+ ];
254
+ const chunks = chunkArray(originalIds, getBatchSize(trx, 500));
255
+ for (const chunk of chunks){
256
+ let q = trx(tableName).select('*').whereIn(columnToRewrite, chunk);
257
+ if (filter) {
258
+ q = q.where(filter.column, filter.value);
259
+ }
260
+ const rows = await q;
261
+ if (rows.length === 0) continue;
262
+ const toInsert = [];
263
+ for (const row of rows){
264
+ const originalId = normalizeId(row[columnToRewrite]);
265
+ if (originalId == null) continue;
266
+ const draftIds = pairs.filter((p)=>p.originalId === originalId).map((p)=>p.draftId);
267
+ for (const draftId of draftIds){
268
+ const { id, ...rest } = row;
269
+ toInsert.push({
270
+ ...rest,
271
+ [columnToRewrite]: draftId
272
+ });
273
+ }
274
+ }
275
+ if (toInsert.length > 0) {
276
+ await insertRelationsWithDuplicateHandling({
277
+ trx,
278
+ tableName,
279
+ relations: toInsert,
280
+ context: {
281
+ reason: contextReason,
282
+ tableName
283
+ }
284
+ });
285
+ }
286
+ }
287
+ }
288
+ const recordClonedComponentPair = (clonedComponentPairsCache, componentUid, originalId, draftId)=>{
289
+ if (!clonedComponentPairsCache) {
290
+ return;
291
+ }
292
+ let pairMap = clonedComponentPairsCache.get(componentUid);
293
+ if (!pairMap) {
294
+ pairMap = new Map();
295
+ clonedComponentPairsCache.set(componentUid, pairMap);
296
+ }
297
+ pairMap.set(`${originalId}:${draftId}`, draftId);
298
+ };
299
+ /** Copy media morph rows for this content type (upload table; we are the "related" side). */ async function copyMediaMorphToDraftsForContentType({ trx, uid, publishedToDraftMap }) {
300
+ const morphInfo = getUploadMorphTableInfo();
301
+ if (!morphInfo) return;
302
+ await copyMorphRowsByIdMap({
303
+ trx,
304
+ tableName: morphInfo.tableName,
305
+ columnToRewrite: morphInfo.relatedIdColumnName,
306
+ idMap: publishedToDraftMap,
307
+ filter: {
308
+ column: morphInfo.relatedTypeColumnName,
309
+ value: uid
310
+ },
311
+ contextReason: 'media-morph-draft'
312
+ });
313
+ }
314
+ /** Copy source-side morph rows for this content type (per-attribute tables; we are the "source" side). */ async function copySourceSideMorphRelationsForContentType({ trx, uid, publishedToDraftMap }) {
315
+ const meta = strapi.db.metadata.get(uid);
316
+ if (!meta) return;
317
+ const uploadMorph = getUploadMorphTableInfo();
318
+ const uploadTableName = uploadMorph?.tableName ?? null;
319
+ for (const attribute of Object.values(meta.attributes)){
320
+ if (attribute.type !== 'relation' || !attribute.joinTable?.morphColumn) continue;
321
+ const joinTable = attribute.joinTable;
322
+ if (joinTable.name === uploadTableName) continue;
323
+ // Dynamic zones are represented as morph relations in DB metadata, but their rows live in
324
+ // the shared component join table (`*_cmps`) and are already handled by copyComponentRelations.
325
+ if (joinTable.name.includes('_cmps')) continue;
326
+ const sourceColumnName = joinTable.joinColumn?.name;
327
+ if (!sourceColumnName) continue;
328
+ await copyMorphRowsByIdMap({
329
+ trx,
330
+ tableName: joinTable.name,
331
+ columnToRewrite: sourceColumnName,
332
+ idMap: publishedToDraftMap,
333
+ contextReason: 'source-side-morph-draft'
334
+ });
335
+ }
336
+ }
337
+ /**
338
+ * Copy source-side morph relations for cloned components (each component type's morph attributes).
339
+ */ async function copySourceSideMorphRelationsForClonedComponents({ trx, clonedComponentPairsCache }) {
340
+ const uploadMorph = getUploadMorphTableInfo();
341
+ const uploadTableName = uploadMorph?.tableName ?? null;
342
+ for (const [componentType, cloneMap] of clonedComponentPairsCache.entries()){
343
+ let componentMeta;
344
+ try {
345
+ componentMeta = getComponentMetadataByUid(componentType);
346
+ } catch {
347
+ continue;
348
+ }
349
+ for (const attribute of Object.values(componentMeta.attributes || {})){
350
+ if (attribute.type !== 'relation' || !attribute.joinTable?.morphColumn) continue;
351
+ const joinTable = attribute.joinTable;
352
+ if (joinTable.name === uploadTableName) continue;
353
+ // Component dynamic zones also use `*_cmps` join tables and are cloned recursively.
354
+ // Copying them here would reinsert original component ids and create shared draft/published rows.
355
+ if (joinTable.name.includes('_cmps')) continue;
356
+ const sourceColumnName = joinTable.joinColumn?.name;
357
+ if (!sourceColumnName) continue;
358
+ const pairs = [];
359
+ for (const [key, newComponentId] of cloneMap.entries()){
360
+ const originalId = Number(key.split(':')[0]);
361
+ if (!Number.isNaN(originalId)) {
362
+ pairs.push({
363
+ originalId,
364
+ draftId: newComponentId
365
+ });
366
+ }
367
+ }
368
+ if (pairs.length > 0) {
369
+ await copyMorphRowsByPairs({
370
+ trx,
371
+ tableName: joinTable.name,
372
+ columnToRewrite: sourceColumnName,
373
+ pairs,
374
+ contextReason: 'source-side-morph-draft-pairs'
375
+ });
376
+ }
377
+ }
378
+ }
141
379
  }
142
380
  /**
143
381
  * Splits large input arrays into smaller batches so we can run SQL queries without
@@ -651,7 +889,7 @@ const debug = createDebug('strapi::migration::discard-drafts');
651
889
  * cloning the same component type thousands of times.
652
890
  */ const getComponentMeta = (componentUid)=>{
653
891
  if (!componentMetaCache.has(componentUid)) {
654
- const meta = strapi.db.metadata.get(componentUid);
892
+ const meta = getComponentMetadataByUid(componentUid);
655
893
  componentMetaCache.set(componentUid, meta ?? null);
656
894
  }
657
895
  return componentMetaCache.get(componentUid);
@@ -842,6 +1080,12 @@ const debug = createDebug('strapi::migration::discard-drafts');
842
1080
  if (attribute.type !== 'relation' || !attribute.joinTable) {
843
1081
  continue;
844
1082
  }
1083
+ const rawComponentAttribute = strapi.components[componentUid]?.attributes?.[attributeName];
1084
+ // Component and dynamic zone links need deep cloning of target component rows.
1085
+ // Skip the generic join-table copier so the recursive clone path can handle them.
1086
+ if (rawComponentAttribute?.type === 'component' || rawComponentAttribute?.type === 'dynamiczone') {
1087
+ continue;
1088
+ }
845
1089
  const joinTable = attribute.joinTable;
846
1090
  const sourceColumnName = joinTable.joinColumn.name;
847
1091
  const targetColumnName = joinTable.inverseJoinColumn.name;
@@ -908,7 +1152,7 @@ const debug = createDebug('strapi::migration::discard-drafts');
908
1152
  /**
909
1153
  * Clones a component row (including nested relations) so the newly created draft entity
910
1154
  * owns its own copy, matching what the document service would have produced.
911
- */ async function cloneComponentInstance({ trx, componentUid, componentId, parentUid, parentPublishedToDraftMap, draftMapCache, isForDraftEntity = true, reverseMapCache }) {
1155
+ */ async function cloneComponentInstance({ trx, componentUid, componentId, parentUid, parentPublishedToDraftMap, draftMapCache, clonedComponentPairsCache, isForDraftEntity = true, reverseMapCache }) {
912
1156
  const componentMeta = getComponentMeta(componentUid);
913
1157
  if (!componentMeta) {
914
1158
  return componentId;
@@ -975,7 +1219,74 @@ const debug = createDebug('strapi::migration::discard-drafts');
975
1219
  if (Number.isNaN(newComponentId)) {
976
1220
  throw new Error(`Invalid cloned component identifier for ${componentUid} (id: ${componentId})`);
977
1221
  }
1222
+ recordClonedComponentPair(clonedComponentPairsCache, componentUid, Number(componentPrimaryKey), newComponentId);
978
1223
  await cloneComponentRelationJoinTables(trx, componentMeta, componentUid, Number(componentPrimaryKey), newComponentId, parentUid, parentPublishedToDraftMap, draftMapCache, isForDraftEntity, reverseMapCache);
1224
+ // Clone nested components (component and dynamiczone attributes) so draft has its own copy
1225
+ const componentSchema = strapi.components[componentUid];
1226
+ const collectionName = componentSchema?.collectionName;
1227
+ if (collectionName && componentSchema?.attributes) {
1228
+ const identifiers = strapi.db.metadata.identifiers;
1229
+ const entityIdCol = getComponentJoinColumnEntityName(identifiers);
1230
+ const componentIdCol = getComponentJoinColumnInverseName(identifiers);
1231
+ const componentTypeCol = getComponentTypeColumn(identifiers);
1232
+ const fieldCol = identifiers.FIELD_COLUMN;
1233
+ // Use the raw component schema here: DB metadata transforms component/DZ attrs into relations,
1234
+ // so their original `type` values are only available on the schema definition.
1235
+ for (const [attrName, attr] of Object.entries(componentSchema.attributes || {})){
1236
+ if (attr.type === 'component') {
1237
+ const nestedJoinTableName = getComponentJoinTableName(collectionName, identifiers);
1238
+ if (!await ensureTableExists(trx, nestedJoinTableName)) continue;
1239
+ const nestedRows = await trx(nestedJoinTableName).select('*').where(entityIdCol, componentPrimaryKey).where(componentTypeCol, attr.component).where(fieldCol, attrName);
1240
+ for (const row of nestedRows){
1241
+ const nestedId = Number(row[componentIdCol]);
1242
+ if (Number.isNaN(nestedId)) continue;
1243
+ const newNestedId = await cloneComponentInstance({
1244
+ trx,
1245
+ componentUid: attr.component,
1246
+ componentId: nestedId,
1247
+ parentUid: componentUid,
1248
+ parentPublishedToDraftMap,
1249
+ draftMapCache,
1250
+ clonedComponentPairsCache,
1251
+ isForDraftEntity,
1252
+ reverseMapCache
1253
+ });
1254
+ const { id, ...rest } = row;
1255
+ await insertRowWithDuplicateHandling(trx, nestedJoinTableName, {
1256
+ ...rest,
1257
+ [entityIdCol]: newComponentId,
1258
+ [componentIdCol]: newNestedId
1259
+ });
1260
+ }
1261
+ } else if (attr.type === 'dynamiczone' && Array.isArray(attr.components)) {
1262
+ const dzJoinTableName = getDzJoinTableName(collectionName, identifiers);
1263
+ if (!await ensureTableExists(trx, dzJoinTableName)) continue;
1264
+ const dzRows = await trx(dzJoinTableName).select('*').where(entityIdCol, componentPrimaryKey).where(fieldCol, attrName);
1265
+ for (const row of dzRows){
1266
+ const nestedType = row[componentTypeCol];
1267
+ const nestedId = Number(row[componentIdCol]);
1268
+ if (!nestedType || Number.isNaN(nestedId)) continue;
1269
+ const newNestedId = await cloneComponentInstance({
1270
+ trx,
1271
+ componentUid: nestedType,
1272
+ componentId: nestedId,
1273
+ parentUid: componentUid,
1274
+ parentPublishedToDraftMap,
1275
+ draftMapCache,
1276
+ clonedComponentPairsCache,
1277
+ isForDraftEntity,
1278
+ reverseMapCache
1279
+ });
1280
+ const { id, ...rest } = row;
1281
+ await insertRowWithDuplicateHandling(trx, dzJoinTableName, {
1282
+ ...rest,
1283
+ [entityIdCol]: newComponentId,
1284
+ [componentIdCol]: newNestedId
1285
+ });
1286
+ }
1287
+ }
1288
+ }
1289
+ }
979
1290
  return newComponentId;
980
1291
  }
981
1292
  /**
@@ -1611,7 +1922,7 @@ const debug = createDebug('strapi::migration::discard-drafts');
1611
1922
  ];
1612
1923
  const draftMapCache = new Map();
1613
1924
  for (const componentType of componentTypes){
1614
- const componentMeta = strapi.db.metadata.get(componentType);
1925
+ const componentMeta = getComponentMetadataByUid(componentType);
1615
1926
  if (!componentMeta) continue;
1616
1927
  for (const [, attr] of Object.entries(componentMeta.attributes || {})){
1617
1928
  if (attr.type !== 'relation' || !attr.joinTable) continue;
@@ -1714,6 +2025,152 @@ const debug = createDebug('strapi::migration::discard-drafts');
1714
2025
  }
1715
2026
  }
1716
2027
  }
2028
+ /**
2029
+ * Fix published entities' component relations so they point to published targets (not draft).
2030
+ * After duplicating nested components, the published tree keeps the original components;
2031
+ * their relation _lnk rows must point to published targets so the published view resolves them.
2032
+ */ async function fixPublishedComponentRelationTargets({ trx, uid }) {
2033
+ const meta = strapi.db.metadata.get(uid);
2034
+ if (!meta) return;
2035
+ const contentType = strapi.contentTypes[uid];
2036
+ const collectionName = contentType?.collectionName;
2037
+ if (!collectionName) return;
2038
+ const identifiers = strapi.db.metadata.identifiers;
2039
+ const joinTableName = getComponentJoinTableName(collectionName, identifiers);
2040
+ const entityIdColumn = getComponentJoinColumnEntityName(identifiers);
2041
+ const componentIdColumn = getComponentJoinColumnInverseName(identifiers);
2042
+ const componentTypeColumn = getComponentTypeColumn(identifiers);
2043
+ if (!await ensureTableExists(trx, joinTableName)) return;
2044
+ const publishedIds = (await trx(meta.tableName).select('id').whereNotNull('published_at')).map((r)=>Number(r.id));
2045
+ if (publishedIds.length === 0) return;
2046
+ const reverseMapCache = new Map();
2047
+ const typeToIds = new Map();
2048
+ let currentLevelByType = new Map();
2049
+ currentLevelByType.set(uid, publishedIds);
2050
+ const maxLevels = 10;
2051
+ for(let level = 0; level < maxLevels; level += 1){
2052
+ const nextLevelByType = new Map();
2053
+ if (level === 0) {
2054
+ const chunks = chunkArray(publishedIds, getBatchSize(trx, 1000));
2055
+ for (const chunk of chunks){
2056
+ const rows = await trx(joinTableName).select(componentIdColumn, componentTypeColumn).whereIn(entityIdColumn, chunk);
2057
+ for (const row of rows){
2058
+ const type = row[componentTypeColumn];
2059
+ const id = Number(row[componentIdColumn]);
2060
+ if (!type || Number.isNaN(id)) continue;
2061
+ if (!typeToIds.has(type)) typeToIds.set(type, new Set());
2062
+ typeToIds.get(type).add(id);
2063
+ if (!nextLevelByType.has(type)) nextLevelByType.set(type, []);
2064
+ nextLevelByType.get(type).push(id);
2065
+ }
2066
+ }
2067
+ } else {
2068
+ for (const [compType, ids] of currentLevelByType.entries()){
2069
+ if (compType === uid) continue;
2070
+ const compSchema = strapi.components[compType];
2071
+ if (!compSchema?.collectionName) continue;
2072
+ const nestedTable = getComponentJoinTableName(compSchema.collectionName, identifiers);
2073
+ if (!await ensureTableExists(trx, nestedTable)) continue;
2074
+ const idChunks = chunkArray(ids, getBatchSize(trx, 1000));
2075
+ for (const idChunk of idChunks){
2076
+ const rows = await trx(nestedTable).select(componentIdColumn, componentTypeColumn).whereIn(entityIdColumn, idChunk);
2077
+ for (const row of rows){
2078
+ const type = row[componentTypeColumn];
2079
+ const id = Number(row[componentIdColumn]);
2080
+ if (!type || Number.isNaN(id)) continue;
2081
+ if (!typeToIds.has(type)) typeToIds.set(type, new Set());
2082
+ typeToIds.get(type).add(id);
2083
+ if (!nextLevelByType.has(type)) nextLevelByType.set(type, []);
2084
+ nextLevelByType.get(type).push(id);
2085
+ }
2086
+ }
2087
+ }
2088
+ }
2089
+ if (nextLevelByType.size === 0) break;
2090
+ currentLevelByType = nextLevelByType;
2091
+ }
2092
+ for (const [componentType, componentIds] of typeToIds.entries()){
2093
+ const componentMeta = getComponentMetadataByUid(componentType);
2094
+ if (!componentMeta) continue;
2095
+ const ids = Array.from(componentIds);
2096
+ if (ids.length === 0) continue;
2097
+ for (const [, attr] of Object.entries(componentMeta.attributes || {})){
2098
+ if (attr.type !== 'relation' || !attr.joinTable) continue;
2099
+ const targetUid = attr.target;
2100
+ if (!targetUid) continue;
2101
+ const targetContentType = strapi.contentTypes[targetUid];
2102
+ if (!targetContentType?.options?.draftAndPublish) continue;
2103
+ const relationJoinTable = attr.joinTable.name;
2104
+ const sourceColumn = attr.joinTable.joinColumn.name;
2105
+ const targetColumn = attr.joinTable.inverseJoinColumn.name;
2106
+ if (!await ensureTableExists(trx, relationJoinTable)) continue;
2107
+ const relations = await trx(relationJoinTable).whereIn(sourceColumn, ids).select('id', sourceColumn, targetColumn);
2108
+ if (relations.length === 0) continue;
2109
+ const targetMeta = strapi.db.metadata.get(targetUid);
2110
+ if (!targetMeta) continue;
2111
+ const targetIdList = [
2112
+ ...new Set(relations.map((r)=>r[targetColumn]).filter(Boolean))
2113
+ ];
2114
+ if (targetIdList.length === 0) continue;
2115
+ const targets = await trx(targetMeta.tableName).whereIn('id', targetIdList).select('id', 'published_at');
2116
+ const targetState = new Map(targets.map((t)=>[
2117
+ Number(t.id),
2118
+ t.published_at !== null ? 'published' : 'draft'
2119
+ ]));
2120
+ const draftToPublished = await getDraftToPublishedMap(trx, targetUid, reverseMapCache);
2121
+ if (!draftToPublished || draftToPublished.size === 0) continue;
2122
+ const relationsToUpdate = [];
2123
+ for (const relation of relations){
2124
+ const targetId = Number(relation[targetColumn]);
2125
+ if (targetState.get(targetId) !== 'draft') continue;
2126
+ const publishedId = draftToPublished.get(targetId);
2127
+ if (publishedId == null) continue;
2128
+ relationsToUpdate.push({
2129
+ relationId: relation.id,
2130
+ sourceId: Number(relation[sourceColumn]),
2131
+ oldTargetId: targetId,
2132
+ newTargetId: publishedId
2133
+ });
2134
+ }
2135
+ if (relationsToUpdate.length > 0) {
2136
+ const updateChunks = chunkArray(relationsToUpdate, getBatchSize(trx, 100));
2137
+ for (const updateChunk of updateChunks){
2138
+ const existingRelationMap = await buildExistingRelationMap({
2139
+ trx,
2140
+ tableName: relationJoinTable,
2141
+ sourceColumnName: sourceColumn,
2142
+ targetColumnName: targetColumn,
2143
+ updates: updateChunk.map((u)=>({
2144
+ sourceId: u.sourceId,
2145
+ newTargetId: u.newTargetId
2146
+ })),
2147
+ batchSize: getBatchSize(trx, 100)
2148
+ });
2149
+ for (const update of updateChunk){
2150
+ const key = `${update.sourceId}_${update.newTargetId}`;
2151
+ const existingRelationId = existingRelationMap.get(key);
2152
+ if (existingRelationId != null && existingRelationId !== update.relationId) {
2153
+ await trx(relationJoinTable).where('id', update.relationId).delete();
2154
+ continue;
2155
+ }
2156
+ try {
2157
+ await trx(relationJoinTable).where('id', update.relationId).update({
2158
+ [targetColumn]: update.newTargetId
2159
+ });
2160
+ existingRelationMap.set(key, update.relationId);
2161
+ } catch (error) {
2162
+ if (isDuplicateEntryError(error)) {
2163
+ await trx(relationJoinTable).where('id', update.relationId).delete();
2164
+ } else {
2165
+ throw error;
2166
+ }
2167
+ }
2168
+ }
2169
+ }
2170
+ }
2171
+ }
2172
+ }
2173
+ }
1717
2174
  /**
1718
2175
  * Copy component relations from published entries to draft entries
1719
2176
  */ async function copyComponentRelations({ trx, uid, publishedToDraftMap }) {
@@ -1748,6 +2205,7 @@ const debug = createDebug('strapi::migration::discard-drafts');
1748
2205
  continue;
1749
2206
  }
1750
2207
  const componentCloneCache = new Map();
2208
+ const clonedComponentPairsCache = new Map();
1751
2209
  const componentTargetDraftMapCache = new Map();
1752
2210
  const componentTargetReverseMapCache = new Map();
1753
2211
  const componentHierarchyCaches = {
@@ -1819,6 +2277,7 @@ const debug = createDebug('strapi::migration::discard-drafts');
1819
2277
  parentUid: uid,
1820
2278
  parentPublishedToDraftMap: publishedToDraftMap,
1821
2279
  draftMapCache: componentTargetDraftMapCache,
2280
+ clonedComponentPairsCache,
1822
2281
  isForDraftEntity: true,
1823
2282
  reverseMapCache: componentTargetReverseMapCache
1824
2283
  });
@@ -1909,6 +2368,40 @@ const debug = createDebug('strapi::migration::discard-drafts');
1909
2368
  });
1910
2369
  }
1911
2370
  }
2371
+ // Copy media morph rows for cloned components so draft components have the same media as published
2372
+ const morphInfo = getUploadMorphTableInfo();
2373
+ if (morphInfo) {
2374
+ for (const [componentType, cloneMap] of clonedComponentPairsCache.entries()){
2375
+ const pairs = [];
2376
+ for (const [key, newComponentId] of cloneMap.entries()){
2377
+ const originalId = Number(key.split(':')[0]);
2378
+ if (!Number.isNaN(originalId)) {
2379
+ pairs.push({
2380
+ originalId,
2381
+ draftId: newComponentId
2382
+ });
2383
+ }
2384
+ }
2385
+ if (pairs.length > 0) {
2386
+ await copyMorphRowsByPairs({
2387
+ trx,
2388
+ tableName: morphInfo.tableName,
2389
+ columnToRewrite: morphInfo.relatedIdColumnName,
2390
+ pairs,
2391
+ filter: {
2392
+ column: morphInfo.relatedTypeColumnName,
2393
+ value: componentType
2394
+ },
2395
+ contextReason: 'media-morph-draft-components'
2396
+ });
2397
+ }
2398
+ }
2399
+ }
2400
+ // Copy any other morph relations where cloned components are the source (morphToOne/morphToMany)
2401
+ await copySourceSideMorphRelationsForClonedComponents({
2402
+ trx,
2403
+ clonedComponentPairsCache
2404
+ });
1912
2405
  }
1913
2406
  }
1914
2407
  /**
@@ -1977,6 +2470,18 @@ const debug = createDebug('strapi::migration::discard-drafts');
1977
2470
  }
1978
2471
  strapi.log.info('[discard-drafts] Stage 4/5 complete');
1979
2472
  /**
2473
+ * Fix published entities' component relations to point to published targets (not draft).
2474
+ * Ensures the published view can resolve nested component relations after duplication.
2475
+ */ strapi.log.info('[discard-drafts] Stage 4b/5 – fixing published component relations to published targets');
2476
+ for (const model of dpModels){
2477
+ debug(` • fixing published component relations for ${model.uid}`);
2478
+ await fixPublishedComponentRelationTargets({
2479
+ trx,
2480
+ uid: model.uid
2481
+ });
2482
+ }
2483
+ strapi.log.info('[discard-drafts] Stage 4b/5 complete');
2484
+ /**
1980
2485
  * Update JoinColumn relations (foreign keys) to point to draft versions
1981
2486
  * This matches discard() behavior: drafts relate to drafts
1982
2487
  */ strapi.log.info('[discard-drafts] Stage 5/5 – updating foreign key references to draft targets');