@strapi/core 0.0.0-experimental.8478bb287dfba93afd007cad7697c0f88853a2d8 → 0.0.0-experimental.848e0ac442910c1ad22a7c5eaab07088827fb53c

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.

Potentially problematic release.


This version of @strapi/core might be problematic. Click here for more details.

Files changed (190) hide show
  1. package/dist/Strapi.d.ts +1 -0
  2. package/dist/Strapi.d.ts.map +1 -1
  3. package/dist/Strapi.js +20 -4
  4. package/dist/Strapi.js.map +1 -1
  5. package/dist/Strapi.mjs +20 -4
  6. package/dist/Strapi.mjs.map +1 -1
  7. package/dist/configuration/config-loader.js.map +1 -1
  8. package/dist/configuration/config-loader.mjs.map +1 -1
  9. package/dist/configuration/urls.js.map +1 -1
  10. package/dist/configuration/urls.mjs.map +1 -1
  11. package/dist/constants.d.ts +3 -0
  12. package/dist/constants.d.ts.map +1 -0
  13. package/dist/constants.js +6 -0
  14. package/dist/constants.js.map +1 -0
  15. package/dist/constants.mjs +4 -0
  16. package/dist/constants.mjs.map +1 -0
  17. package/dist/container.js.map +1 -1
  18. package/dist/container.mjs.map +1 -1
  19. package/dist/core-api/routes/index.js.map +1 -1
  20. package/dist/core-api/routes/index.mjs.map +1 -1
  21. package/dist/core-api/routes/validation/mappers.d.ts.map +1 -1
  22. package/dist/core-api/routes/validation/mappers.js +35 -0
  23. package/dist/core-api/routes/validation/mappers.js.map +1 -1
  24. package/dist/core-api/routes/validation/mappers.mjs +35 -0
  25. package/dist/core-api/routes/validation/mappers.mjs.map +1 -1
  26. package/dist/core-api/routes/validation/utils.js.map +1 -1
  27. package/dist/core-api/routes/validation/utils.mjs.map +1 -1
  28. package/dist/core-api/service/collection-type.js.map +1 -1
  29. package/dist/core-api/service/collection-type.mjs.map +1 -1
  30. package/dist/core-api/service/single-type.js.map +1 -1
  31. package/dist/core-api/service/single-type.mjs.map +1 -1
  32. package/dist/domain/content-type/index.js.map +1 -1
  33. package/dist/domain/content-type/index.mjs.map +1 -1
  34. package/dist/domain/module/index.js.map +1 -1
  35. package/dist/domain/module/index.mjs.map +1 -1
  36. package/dist/ee/index.js.map +1 -1
  37. package/dist/ee/index.mjs.map +1 -1
  38. package/dist/ee/license.js +1 -2
  39. package/dist/ee/license.js.map +1 -1
  40. package/dist/ee/license.mjs +1 -2
  41. package/dist/ee/license.mjs.map +1 -1
  42. package/dist/loaders/apis.js.map +1 -1
  43. package/dist/loaders/apis.mjs.map +1 -1
  44. package/dist/loaders/components.js.map +1 -1
  45. package/dist/loaders/components.mjs.map +1 -1
  46. package/dist/loaders/plugins/get-enabled-plugins.js.map +1 -1
  47. package/dist/loaders/plugins/get-enabled-plugins.mjs.map +1 -1
  48. package/dist/loaders/plugins/index.js +1 -1
  49. package/dist/loaders/plugins/index.js.map +1 -1
  50. package/dist/loaders/plugins/index.mjs +1 -1
  51. package/dist/loaders/plugins/index.mjs.map +1 -1
  52. package/dist/loaders/src-index.js.map +1 -1
  53. package/dist/loaders/src-index.mjs.map +1 -1
  54. package/dist/middlewares/logger.js.map +1 -1
  55. package/dist/middlewares/logger.mjs.map +1 -1
  56. package/dist/middlewares/response-time.js.map +1 -1
  57. package/dist/middlewares/response-time.mjs.map +1 -1
  58. package/dist/middlewares/security.d.ts.map +1 -1
  59. package/dist/middlewares/security.js +2 -15
  60. package/dist/middlewares/security.js.map +1 -1
  61. package/dist/middlewares/security.mjs +2 -15
  62. package/dist/middlewares/security.mjs.map +1 -1
  63. package/dist/migrations/database/5.0.0-discard-drafts.d.ts +21 -7
  64. package/dist/migrations/database/5.0.0-discard-drafts.d.ts.map +1 -1
  65. package/dist/migrations/database/5.0.0-discard-drafts.js +1144 -60
  66. package/dist/migrations/database/5.0.0-discard-drafts.js.map +1 -1
  67. package/dist/migrations/database/5.0.0-discard-drafts.mjs +1145 -61
  68. package/dist/migrations/database/5.0.0-discard-drafts.mjs.map +1 -1
  69. package/dist/migrations/first-published-at.js.map +1 -1
  70. package/dist/migrations/first-published-at.mjs.map +1 -1
  71. package/dist/package.json.js +13 -12
  72. package/dist/package.json.js.map +1 -1
  73. package/dist/package.json.mjs +13 -12
  74. package/dist/package.json.mjs.map +1 -1
  75. package/dist/providers/index.d.ts.map +1 -1
  76. package/dist/providers/index.js +2 -0
  77. package/dist/providers/index.js.map +1 -1
  78. package/dist/providers/index.mjs +2 -0
  79. package/dist/providers/index.mjs.map +1 -1
  80. package/dist/providers/session-manager.d.ts +3 -0
  81. package/dist/providers/session-manager.d.ts.map +1 -0
  82. package/dist/providers/session-manager.js +23 -0
  83. package/dist/providers/session-manager.js.map +1 -0
  84. package/dist/providers/session-manager.mjs +21 -0
  85. package/dist/providers/session-manager.mjs.map +1 -0
  86. package/dist/registries/apis.js.map +1 -1
  87. package/dist/registries/apis.mjs.map +1 -1
  88. package/dist/registries/custom-fields.js.map +1 -1
  89. package/dist/registries/custom-fields.mjs.map +1 -1
  90. package/dist/registries/namespace.js.map +1 -1
  91. package/dist/registries/namespace.mjs.map +1 -1
  92. package/dist/registries/plugins.js.map +1 -1
  93. package/dist/registries/plugins.mjs.map +1 -1
  94. package/dist/registries/policies.js.map +1 -1
  95. package/dist/registries/policies.mjs.map +1 -1
  96. package/dist/services/config.js.map +1 -1
  97. package/dist/services/config.mjs.map +1 -1
  98. package/dist/services/content-api/index.js.map +1 -1
  99. package/dist/services/content-api/index.mjs.map +1 -1
  100. package/dist/services/content-api/permissions/index.js.map +1 -1
  101. package/dist/services/content-api/permissions/index.mjs.map +1 -1
  102. package/dist/services/content-source-maps.d.ts +2 -1
  103. package/dist/services/content-source-maps.d.ts.map +1 -1
  104. package/dist/services/content-source-maps.js +34 -9
  105. package/dist/services/content-source-maps.js.map +1 -1
  106. package/dist/services/content-source-maps.mjs +34 -9
  107. package/dist/services/content-source-maps.mjs.map +1 -1
  108. package/dist/services/core-store.js.map +1 -1
  109. package/dist/services/core-store.mjs.map +1 -1
  110. package/dist/services/document-service/components.d.ts +31 -1
  111. package/dist/services/document-service/components.d.ts.map +1 -1
  112. package/dist/services/document-service/components.js +109 -0
  113. package/dist/services/document-service/components.js.map +1 -1
  114. package/dist/services/document-service/components.mjs +107 -1
  115. package/dist/services/document-service/components.mjs.map +1 -1
  116. package/dist/services/document-service/repository.d.ts.map +1 -1
  117. package/dist/services/document-service/repository.js +5 -1
  118. package/dist/services/document-service/repository.js.map +1 -1
  119. package/dist/services/document-service/repository.mjs +6 -2
  120. package/dist/services/document-service/repository.mjs.map +1 -1
  121. package/dist/services/document-service/transform/fields.js.map +1 -1
  122. package/dist/services/document-service/transform/fields.mjs.map +1 -1
  123. package/dist/services/document-service/transform/id-map.js.map +1 -1
  124. package/dist/services/document-service/transform/id-map.mjs.map +1 -1
  125. package/dist/services/document-service/utils/clean-component-join-table.d.ts +7 -0
  126. package/dist/services/document-service/utils/clean-component-join-table.d.ts.map +1 -0
  127. package/dist/services/document-service/utils/clean-component-join-table.js +145 -0
  128. package/dist/services/document-service/utils/clean-component-join-table.js.map +1 -0
  129. package/dist/services/document-service/utils/clean-component-join-table.mjs +143 -0
  130. package/dist/services/document-service/utils/clean-component-join-table.mjs.map +1 -0
  131. package/dist/services/document-service/utils/unidirectional-relations.d.ts +19 -2
  132. package/dist/services/document-service/utils/unidirectional-relations.d.ts.map +1 -1
  133. package/dist/services/document-service/utils/unidirectional-relations.js +21 -6
  134. package/dist/services/document-service/utils/unidirectional-relations.js.map +1 -1
  135. package/dist/services/document-service/utils/unidirectional-relations.mjs +21 -6
  136. package/dist/services/document-service/utils/unidirectional-relations.mjs.map +1 -1
  137. package/dist/services/entity-service/index.js.map +1 -1
  138. package/dist/services/entity-service/index.mjs.map +1 -1
  139. package/dist/services/entity-validator/blocks-validator.js.map +1 -1
  140. package/dist/services/entity-validator/blocks-validator.mjs.map +1 -1
  141. package/dist/services/entity-validator/index.js.map +1 -1
  142. package/dist/services/entity-validator/index.mjs.map +1 -1
  143. package/dist/services/metrics/index.js +2 -1
  144. package/dist/services/metrics/index.js.map +1 -1
  145. package/dist/services/metrics/index.mjs +2 -1
  146. package/dist/services/metrics/index.mjs.map +1 -1
  147. package/dist/services/metrics/middleware.d.ts +2 -1
  148. package/dist/services/metrics/middleware.d.ts.map +1 -1
  149. package/dist/services/metrics/middleware.js +2 -2
  150. package/dist/services/metrics/middleware.js.map +1 -1
  151. package/dist/services/metrics/middleware.mjs +2 -2
  152. package/dist/services/metrics/middleware.mjs.map +1 -1
  153. package/dist/services/metrics/sender.d.ts.map +1 -1
  154. package/dist/services/metrics/sender.js +2 -1
  155. package/dist/services/metrics/sender.js.map +1 -1
  156. package/dist/services/metrics/sender.mjs +2 -1
  157. package/dist/services/metrics/sender.mjs.map +1 -1
  158. package/dist/services/server/compose-endpoint.js.map +1 -1
  159. package/dist/services/server/compose-endpoint.mjs.map +1 -1
  160. package/dist/services/server/index.js.map +1 -1
  161. package/dist/services/server/index.mjs.map +1 -1
  162. package/dist/services/server/middleware.js.map +1 -1
  163. package/dist/services/server/middleware.mjs.map +1 -1
  164. package/dist/services/server/register-routes.js.map +1 -1
  165. package/dist/services/server/register-routes.mjs.map +1 -1
  166. package/dist/services/server/routing.js.map +1 -1
  167. package/dist/services/server/routing.mjs.map +1 -1
  168. package/dist/services/session-manager.d.ts +167 -0
  169. package/dist/services/session-manager.d.ts.map +1 -0
  170. package/dist/services/session-manager.js +529 -0
  171. package/dist/services/session-manager.js.map +1 -0
  172. package/dist/services/session-manager.mjs +526 -0
  173. package/dist/services/session-manager.mjs.map +1 -0
  174. package/dist/services/webhook-runner.js +2 -2
  175. package/dist/services/webhook-runner.js.map +1 -1
  176. package/dist/services/webhook-runner.mjs +2 -2
  177. package/dist/services/webhook-runner.mjs.map +1 -1
  178. package/dist/services/worker-queue.js +2 -2
  179. package/dist/services/worker-queue.js.map +1 -1
  180. package/dist/services/worker-queue.mjs +2 -2
  181. package/dist/services/worker-queue.mjs.map +1 -1
  182. package/dist/utils/fetch.js.map +1 -1
  183. package/dist/utils/fetch.mjs.map +1 -1
  184. package/dist/utils/filepath-to-prop-path.js.map +1 -1
  185. package/dist/utils/filepath-to-prop-path.mjs.map +1 -1
  186. package/dist/utils/load-config-file.js.map +1 -1
  187. package/dist/utils/load-config-file.mjs.map +1 -1
  188. package/dist/utils/startup-logger.js.map +1 -1
  189. package/dist/utils/startup-logger.mjs.map +1 -1
  190. package/package.json +13 -12
@@ -1,5 +1,7 @@
1
- import { async, contentTypes } from '@strapi/utils';
2
- import { createDocumentService } from '../../services/document-service/index.mjs';
1
+ import { createId } from '@paralleldrive/cuid2';
2
+ import { contentTypes } from '@strapi/utils';
3
+ import createDebug from 'debug';
4
+ import { getComponentJoinTableName, getComponentJoinColumnEntityName, getComponentJoinColumnInverseName, getComponentTypeColumn } from '../../utils/transform-content-types-to-models.mjs';
3
5
 
4
6
  /**
5
7
  * Check if the model has draft and publish enabled.
@@ -47,7 +49,9 @@ import { createDocumentService } from '../../services/document-service/index.mjs
47
49
  ])).insert((subQb)=>{
48
50
  // SELECT columnName1, columnName2, columnName3, ...
49
51
  subQb.select(...scalarAttributes.map((att)=>{
50
- // Override 'publishedAt' and 'updatedAt' attributes
52
+ // NOTE: these literals reference Strapi's built-in system columns. They never get shortened by
53
+ // the identifier migration (5.0.0-01-convert-identifiers-long-than-max-length) so we can safely
54
+ // compare/use them directly here.
51
55
  if (att === 'published_at') {
52
56
  return trx.raw('NULL as ??', 'published_at');
53
57
  }
@@ -56,6 +60,1144 @@ import { createDocumentService } from '../../services/document-service/index.mjs
56
60
  .whereNotNull('published_at');
57
61
  });
58
62
  }
63
+ /**
64
+ * Copy relations from published entries to draft entries using direct database queries.
65
+ * This replaces the need to call discardDraft for each entry.
66
+ */ async function copyRelationsToDrafts({ db, trx, uid }) {
67
+ const meta = db.metadata.get(uid);
68
+ if (!meta) {
69
+ return;
70
+ }
71
+ // Create mapping from published entry ID to draft entry ID
72
+ const publishedToDraftMap = await buildPublishedToDraftMap({
73
+ trx,
74
+ uid,
75
+ meta
76
+ });
77
+ if (!publishedToDraftMap || publishedToDraftMap.size === 0) {
78
+ return;
79
+ }
80
+ // Copy relations for this content type
81
+ await copyRelationsForContentType({
82
+ trx,
83
+ uid,
84
+ publishedToDraftMap
85
+ });
86
+ // Copy relations from other content types that target this content type
87
+ await copyRelationsFromOtherContentTypes({
88
+ trx,
89
+ uid,
90
+ publishedToDraftMap
91
+ });
92
+ // Copy relations from this content type that target other content types (category 3)
93
+ await copyRelationsToOtherContentTypes({
94
+ trx,
95
+ uid,
96
+ publishedToDraftMap
97
+ });
98
+ // Copy component relations from published entries to draft entries
99
+ await copyComponentRelations({
100
+ trx,
101
+ uid,
102
+ publishedToDraftMap
103
+ });
104
+ }
105
+ /**
106
+ * Helper to batch process arrays in chunks
107
+ */ function chunkArray(array, chunkSize) {
108
+ const chunks = [];
109
+ for(let i = 0; i < array.length; i += chunkSize){
110
+ chunks.push(array.slice(i, i + chunkSize));
111
+ }
112
+ return chunks;
113
+ }
114
+ const applyJoinTableOrdering = (qb, joinTable, sourceColumnName)=>{
115
+ const seenColumns = new Set();
116
+ const enqueueColumn = (column, direction = 'asc')=>{
117
+ if (!column || seenColumns.has(column)) {
118
+ return;
119
+ }
120
+ seenColumns.add(column);
121
+ qb.orderBy(column, direction);
122
+ };
123
+ enqueueColumn(sourceColumnName, 'asc');
124
+ if (Array.isArray(joinTable?.orderBy)) {
125
+ for (const clause of joinTable.orderBy){
126
+ if (!clause || typeof clause !== 'object') {
127
+ continue;
128
+ }
129
+ const [column, direction] = Object.entries(clause)[0] ?? [];
130
+ if (!column) {
131
+ continue;
132
+ }
133
+ const normalizedDirection = typeof direction === 'string' && direction.toLowerCase() === 'desc' ? 'desc' : 'asc';
134
+ enqueueColumn(column, normalizedDirection);
135
+ }
136
+ }
137
+ enqueueColumn(joinTable?.orderColumnName, 'asc');
138
+ enqueueColumn(joinTable?.orderColumn, 'asc');
139
+ enqueueColumn('id', 'asc');
140
+ };
141
+ const componentParentSchemasCache = new Map();
142
+ const joinTableExistsCache = new Map();
143
+ const componentMetaCache = new Map();
144
+ const DUPLICATE_ERROR_CODES = new Set([
145
+ '23505',
146
+ 'ER_DUP_ENTRY',
147
+ 'SQLITE_CONSTRAINT_UNIQUE'
148
+ ]);
149
+ const debug = createDebug('strapi::migration::discard-drafts');
150
+ const normalizeId = (value)=>{
151
+ if (value == null) {
152
+ return null;
153
+ }
154
+ const num = Number(value);
155
+ if (Number.isNaN(num)) {
156
+ return null;
157
+ }
158
+ return num;
159
+ };
160
+ const getMappedValue = (map, key)=>{
161
+ if (!map) {
162
+ return undefined;
163
+ }
164
+ const normalized = normalizeId(key);
165
+ if (normalized == null) {
166
+ return undefined;
167
+ }
168
+ return map.get(normalized);
169
+ };
170
+ const resolveInsertedId = (insertResult)=>{
171
+ if (insertResult == null) {
172
+ return null;
173
+ }
174
+ if (typeof insertResult === 'number') {
175
+ return insertResult;
176
+ }
177
+ if (Array.isArray(insertResult)) {
178
+ if (insertResult.length === 0) {
179
+ return null;
180
+ }
181
+ const first = insertResult[0];
182
+ if (first == null) {
183
+ return null;
184
+ }
185
+ if (typeof first === 'number') {
186
+ return first;
187
+ }
188
+ if (typeof first === 'object') {
189
+ if ('id' in first) {
190
+ return Number(first.id);
191
+ }
192
+ const idKey = Object.keys(first).find((key)=>key.toLowerCase() === 'id');
193
+ if (idKey) {
194
+ return Number(first[idKey]);
195
+ }
196
+ }
197
+ }
198
+ if (typeof insertResult === 'object' && 'id' in insertResult) {
199
+ return Number(insertResult.id);
200
+ }
201
+ return null;
202
+ };
203
+ const isDuplicateEntryError = (error)=>{
204
+ if (!error) {
205
+ return false;
206
+ }
207
+ if (DUPLICATE_ERROR_CODES.has(error.code)) {
208
+ return true;
209
+ }
210
+ const message = typeof error.message === 'string' ? error.message : '';
211
+ return message.includes('duplicate key') || message.includes('UNIQUE constraint failed');
212
+ };
213
+ const insertRowWithDuplicateHandling = async (trx, tableName, row, context = {})=>{
214
+ try {
215
+ const client = trx.client.config.client;
216
+ if (client === 'postgres' || client === 'pg' || client === 'sqlite3' || client === 'better-sqlite3') {
217
+ await trx(tableName).insert(row).onConflict().ignore();
218
+ return;
219
+ }
220
+ if (client === 'mysql' || client === 'mysql2') {
221
+ await trx.raw(`INSERT IGNORE INTO ?? SET ?`, [
222
+ tableName,
223
+ row
224
+ ]);
225
+ return;
226
+ }
227
+ await trx(tableName).insert(row);
228
+ } catch (error) {
229
+ if (!isDuplicateEntryError(error)) {
230
+ const details = JSON.stringify(context);
231
+ const wrapped = new Error(`Failed to insert row into ${tableName}: ${error.message} | context=${details}`);
232
+ wrapped.cause = error;
233
+ throw wrapped;
234
+ }
235
+ }
236
+ };
237
+ function listComponentParentSchemas(componentUid) {
238
+ if (!componentParentSchemasCache.has(componentUid)) {
239
+ const schemas = [
240
+ ...Object.values(strapi.contentTypes),
241
+ ...Object.values(strapi.components)
242
+ ];
243
+ const parents = schemas.filter((schema)=>{
244
+ if (!schema?.attributes) {
245
+ return false;
246
+ }
247
+ return Object.values(schema.attributes).some((attr)=>{
248
+ if (attr.type === 'component') {
249
+ return attr.component === componentUid;
250
+ }
251
+ if (attr.type === 'dynamiczone') {
252
+ return attr.components?.includes(componentUid);
253
+ }
254
+ return false;
255
+ });
256
+ }).map((schema)=>({
257
+ uid: schema.uid,
258
+ collectionName: schema.collectionName
259
+ }));
260
+ componentParentSchemasCache.set(componentUid, parents);
261
+ }
262
+ return componentParentSchemasCache.get(componentUid);
263
+ }
264
+ async function ensureTableExists(trx, tableName) {
265
+ if (!joinTableExistsCache.has(tableName)) {
266
+ const exists = await trx.schema.hasTable(tableName);
267
+ joinTableExistsCache.set(tableName, exists);
268
+ }
269
+ return joinTableExistsCache.get(tableName);
270
+ }
271
+ async function findComponentParentInstance(trx, identifiers, componentUid, componentId, excludeUid, caches) {
272
+ const cacheKey = `${componentUid}:${componentId}:${excludeUid ?? 'ALL'}`;
273
+ if (caches.parentInstanceCache.has(cacheKey)) {
274
+ return caches.parentInstanceCache.get(cacheKey);
275
+ }
276
+ const parentComponentIdColumn = getComponentJoinColumnInverseName(identifiers);
277
+ const parentComponentTypeColumn = getComponentTypeColumn(identifiers);
278
+ const parentEntityIdColumn = getComponentJoinColumnEntityName(identifiers);
279
+ const potentialParents = listComponentParentSchemas(componentUid).filter((schema)=>schema.uid !== excludeUid);
280
+ for (const parentSchema of potentialParents){
281
+ if (!parentSchema.collectionName) {
282
+ continue;
283
+ }
284
+ const parentJoinTableName = getComponentJoinTableName(parentSchema.collectionName, identifiers);
285
+ try {
286
+ if (!await ensureTableExists(trx, parentJoinTableName)) {
287
+ continue;
288
+ }
289
+ const parentRow = await trx(parentJoinTableName).where({
290
+ [parentComponentIdColumn]: componentId,
291
+ [parentComponentTypeColumn]: componentUid
292
+ }).first(parentEntityIdColumn);
293
+ if (parentRow) {
294
+ const parentInstance = {
295
+ uid: parentSchema.uid,
296
+ parentId: parentRow[parentEntityIdColumn]
297
+ };
298
+ caches.parentInstanceCache.set(cacheKey, parentInstance);
299
+ return parentInstance;
300
+ }
301
+ } catch {
302
+ continue;
303
+ }
304
+ }
305
+ caches.parentInstanceCache.set(cacheKey, null);
306
+ return null;
307
+ }
308
+ const getComponentMeta = (componentUid)=>{
309
+ if (!componentMetaCache.has(componentUid)) {
310
+ const meta = strapi.db.metadata.get(componentUid);
311
+ componentMetaCache.set(componentUid, meta ?? null);
312
+ }
313
+ return componentMetaCache.get(componentUid);
314
+ };
315
+ async function hasDraftPublishAncestorForParent(trx, identifiers, parent, caches) {
316
+ const cacheKey = `${parent.uid}:${parent.parentId}`;
317
+ if (caches.parentDpCache.has(cacheKey)) {
318
+ return caches.parentDpCache.get(cacheKey);
319
+ }
320
+ const parentContentType = strapi.contentTypes[parent.uid];
321
+ if (parentContentType) {
322
+ const result = !!parentContentType?.options?.draftAndPublish;
323
+ caches.parentDpCache.set(cacheKey, result);
324
+ return result;
325
+ }
326
+ const parentComponent = strapi.components[parent.uid];
327
+ if (!parentComponent) {
328
+ caches.parentDpCache.set(cacheKey, false);
329
+ return false;
330
+ }
331
+ const result = await hasDraftPublishAncestorForComponent(trx, identifiers, parent.uid, parent.parentId, undefined, caches);
332
+ caches.parentDpCache.set(cacheKey, result);
333
+ return result;
334
+ }
335
+ async function hasDraftPublishAncestorForComponent(trx, identifiers, componentUid, componentId, excludeUid, caches) {
336
+ const cacheKey = `${componentUid}:${componentId}:${'ALL'}`;
337
+ if (caches.ancestorDpCache.has(cacheKey)) {
338
+ return caches.ancestorDpCache.get(cacheKey);
339
+ }
340
+ const parent = await findComponentParentInstance(trx, identifiers, componentUid, componentId, excludeUid, caches);
341
+ if (!parent) {
342
+ caches.ancestorDpCache.set(cacheKey, false);
343
+ return false;
344
+ }
345
+ const result = await hasDraftPublishAncestorForParent(trx, identifiers, parent, caches);
346
+ caches.ancestorDpCache.set(cacheKey, result);
347
+ return result;
348
+ }
349
+ const resolveNowValue = (trx)=>{
350
+ if (typeof trx.fn?.now === 'function') {
351
+ return trx.fn.now();
352
+ }
353
+ return new Date();
354
+ };
355
+ async function getDraftMapForTarget(trx, targetUid, draftMapCache) {
356
+ if (draftMapCache.has(targetUid)) {
357
+ return draftMapCache.get(targetUid) ?? null;
358
+ }
359
+ const targetMeta = strapi.db.metadata.get(targetUid);
360
+ if (!targetMeta) {
361
+ draftMapCache.set(targetUid, null);
362
+ return null;
363
+ }
364
+ const map = await buildPublishedToDraftMap({
365
+ trx,
366
+ uid: targetUid,
367
+ meta: targetMeta,
368
+ options: {
369
+ requireDraftAndPublish: true
370
+ }
371
+ });
372
+ draftMapCache.set(targetUid, map ?? null);
373
+ return map ?? null;
374
+ }
375
+ async function mapTargetId(trx, originalId, targetUid, parentUid, parentPublishedToDraftMap, draftMapCache) {
376
+ if (originalId == null || !targetUid) {
377
+ return originalId;
378
+ }
379
+ if (targetUid === parentUid) {
380
+ return parentPublishedToDraftMap.get(Number(originalId)) ?? originalId;
381
+ }
382
+ const targetMap = await getDraftMapForTarget(trx, targetUid, draftMapCache);
383
+ if (!targetMap) {
384
+ return originalId;
385
+ }
386
+ return targetMap.get(Number(originalId)) ?? originalId;
387
+ }
388
+ const ensureObjectWithoutId = (row)=>{
389
+ const cloned = {
390
+ ...row
391
+ };
392
+ if ('id' in cloned) {
393
+ delete cloned.id;
394
+ }
395
+ return cloned;
396
+ };
397
+ async function cloneComponentRelationJoinTables(trx, componentMeta, componentUid, originalComponentId, newComponentId, parentUid, parentPublishedToDraftMap, draftMapCache) {
398
+ for (const attribute of Object.values(componentMeta.attributes)){
399
+ if (attribute.type !== 'relation' || !attribute.joinTable) {
400
+ continue;
401
+ }
402
+ const joinTable = attribute.joinTable;
403
+ const sourceColumnName = joinTable.joinColumn.name;
404
+ const targetColumnName = joinTable.inverseJoinColumn.name;
405
+ if (!componentMeta.relationsLogPrinted) {
406
+ debug(`[cloneComponentRelationJoinTables] Inspecting join table ${joinTable.name} for component ${componentUid}`);
407
+ componentMeta.relationsLogPrinted = true;
408
+ }
409
+ const relations = await trx(joinTable.name).select('*').where(sourceColumnName, originalComponentId);
410
+ if (relations.length === 0) {
411
+ continue;
412
+ }
413
+ for (const relation of relations){
414
+ const clonedRelation = ensureObjectWithoutId(relation);
415
+ clonedRelation[sourceColumnName] = newComponentId;
416
+ if (targetColumnName in clonedRelation) {
417
+ const originalTargetId = clonedRelation[targetColumnName];
418
+ clonedRelation[targetColumnName] = await mapTargetId(trx, clonedRelation[targetColumnName], attribute.target, parentUid, parentPublishedToDraftMap, draftMapCache);
419
+ debug(`[cloneComponentRelationJoinTables] ${componentUid} join ${joinTable.name}: mapped ${targetColumnName} from ${originalTargetId} to ${clonedRelation[targetColumnName]} (target=${attribute.target})`);
420
+ }
421
+ debug(`[cloneComponentRelationJoinTables] inserting relation into ${joinTable.name} (component=${componentUid}, source=${newComponentId})`);
422
+ await insertRowWithDuplicateHandling(trx, joinTable.name, clonedRelation, {
423
+ componentUid,
424
+ originalComponentId,
425
+ newComponentId,
426
+ joinTable: joinTable.name,
427
+ sourceColumnName,
428
+ targetColumnName,
429
+ targetUid: attribute.target,
430
+ parentUid
431
+ });
432
+ }
433
+ }
434
+ }
435
+ async function cloneComponentInstance({ trx, componentUid, componentId, parentUid, parentPublishedToDraftMap, draftMapCache }) {
436
+ const componentMeta = getComponentMeta(componentUid);
437
+ if (!componentMeta) {
438
+ return componentId;
439
+ }
440
+ const componentTableName = componentMeta.tableName;
441
+ const componentPrimaryKey = Number.isNaN(Number(componentId)) ? componentId : Number(componentId);
442
+ const componentRow = await trx(componentTableName).select('*').where('id', componentPrimaryKey).first();
443
+ if (!componentRow) {
444
+ return componentId;
445
+ }
446
+ const newComponentRow = ensureObjectWithoutId(componentRow);
447
+ // `document_id`, `created_at`, `updated_at` are Strapi system columns whose names remain stable across the
448
+ // identifier-shortening migration, so it’s safe to check them directly here.
449
+ if ('document_id' in newComponentRow) {
450
+ newComponentRow.document_id = createId();
451
+ }
452
+ if ('updated_at' in newComponentRow) {
453
+ newComponentRow.updated_at = resolveNowValue(trx);
454
+ }
455
+ if ('created_at' in newComponentRow && newComponentRow.created_at == null) {
456
+ newComponentRow.created_at = resolveNowValue(trx);
457
+ }
458
+ for (const attribute of Object.values(componentMeta.attributes)){
459
+ if (attribute.type !== 'relation') {
460
+ continue;
461
+ }
462
+ const joinColumn = attribute.joinColumn;
463
+ if (!joinColumn) {
464
+ continue;
465
+ }
466
+ const columnName = joinColumn.name;
467
+ if (!columnName || !(columnName in newComponentRow)) {
468
+ continue;
469
+ }
470
+ newComponentRow[columnName] = await mapTargetId(trx, newComponentRow[columnName], attribute.target, parentUid, parentPublishedToDraftMap, draftMapCache);
471
+ }
472
+ let insertResult;
473
+ try {
474
+ insertResult = await trx(componentTableName).insert(newComponentRow, [
475
+ 'id'
476
+ ]);
477
+ } catch (error) {
478
+ insertResult = await trx(componentTableName).insert(newComponentRow);
479
+ }
480
+ let newComponentId = resolveInsertedId(insertResult);
481
+ if (!newComponentId) {
482
+ if ('document_id' in newComponentRow && newComponentRow.document_id) {
483
+ const insertedRow = await trx(componentTableName).select('id').where('document_id', newComponentRow.document_id).orderBy('id', 'desc').first();
484
+ newComponentId = insertedRow?.id ?? null;
485
+ }
486
+ if (!newComponentId) {
487
+ const insertedRow = await trx(componentTableName).select('id').orderBy('id', 'desc').first();
488
+ newComponentId = insertedRow?.id ?? null;
489
+ }
490
+ }
491
+ if (!newComponentId) {
492
+ throw new Error(`Failed to clone component ${componentUid} (id: ${componentId})`);
493
+ }
494
+ newComponentId = Number(newComponentId);
495
+ if (Number.isNaN(newComponentId)) {
496
+ throw new Error(`Invalid cloned component identifier for ${componentUid} (id: ${componentId})`);
497
+ }
498
+ await cloneComponentRelationJoinTables(trx, componentMeta, componentUid, Number(componentPrimaryKey), newComponentId, parentUid, parentPublishedToDraftMap, draftMapCache);
499
+ return newComponentId;
500
+ }
501
+ async function buildPublishedToDraftMap({ trx, uid, meta, options = {} }) {
502
+ if (!meta) {
503
+ return null;
504
+ }
505
+ const model = strapi.getModel(uid);
506
+ const hasDraftAndPublishEnabled = contentTypes.hasDraftAndPublish(model);
507
+ if (options.requireDraftAndPublish && !hasDraftAndPublishEnabled) {
508
+ return null;
509
+ }
510
+ const [publishedEntries, draftEntries] = await Promise.all([
511
+ // `document_id`, `locale`, and `published_at` are core columns that keep their exact names after the
512
+ // identifier-shortening migration, so selecting them by literal is safe.
513
+ trx(meta.tableName).select([
514
+ 'id',
515
+ 'document_id',
516
+ 'locale'
517
+ ]).whereNotNull('published_at'),
518
+ trx(meta.tableName).select([
519
+ 'id',
520
+ 'document_id',
521
+ 'locale'
522
+ ]).whereNull('published_at')
523
+ ]);
524
+ if (publishedEntries.length === 0 || draftEntries.length === 0) {
525
+ return null;
526
+ }
527
+ const i18nService = strapi.plugin('i18n')?.service('content-types');
528
+ const contentType = strapi.contentTypes[uid];
529
+ const isLocalized = i18nService?.isLocalizedContentType(contentType) ?? false;
530
+ const draftByDocumentId = new Map();
531
+ for (const draft of draftEntries){
532
+ if (!draft.document_id) {
533
+ continue;
534
+ }
535
+ const key = isLocalized ? `${draft.document_id}:${draft.locale || ''}` : draft.document_id;
536
+ const existing = draftByDocumentId.get(key);
537
+ if (!existing) {
538
+ draftByDocumentId.set(key, draft);
539
+ continue;
540
+ }
541
+ const existingId = Number(existing.id);
542
+ const draftId = Number(draft.id);
543
+ if (Number.isNaN(existingId) || Number.isNaN(draftId)) {
544
+ draftByDocumentId.set(key, draft);
545
+ continue;
546
+ }
547
+ if (draftId > existingId) {
548
+ draftByDocumentId.set(key, draft);
549
+ }
550
+ }
551
+ const publishedToDraftMap = new Map();
552
+ for (const published of publishedEntries){
553
+ if (!published.document_id) {
554
+ continue;
555
+ }
556
+ const key = isLocalized ? `${published.document_id}:${published.locale || ''}` : published.document_id;
557
+ const draft = draftByDocumentId.get(key);
558
+ if (draft) {
559
+ const publishedId = normalizeId(published.id);
560
+ const draftId = normalizeId(draft.id);
561
+ if (publishedId == null || draftId == null) {
562
+ continue;
563
+ }
564
+ publishedToDraftMap.set(publishedId, draftId);
565
+ }
566
+ }
567
+ return publishedToDraftMap.size > 0 ? publishedToDraftMap : null;
568
+ }
569
+ /**
570
+ * Copy relations within the same content type (self-referential relations)
571
+ */ async function copyRelationsForContentType({ trx, uid, publishedToDraftMap }) {
572
+ const meta = strapi.db.metadata.get(uid);
573
+ if (!meta) return;
574
+ const publishedIds = Array.from(publishedToDraftMap.keys());
575
+ for (const attribute of Object.values(meta.attributes)){
576
+ if (attribute.type !== 'relation' || attribute.target !== uid) {
577
+ continue;
578
+ }
579
+ const joinTable = attribute.joinTable;
580
+ if (!joinTable) {
581
+ continue;
582
+ }
583
+ // Skip component join tables - they are handled by copyComponentRelations
584
+ if (joinTable.name.includes('_cmps')) {
585
+ continue;
586
+ }
587
+ const { name: sourceColumnName } = joinTable.joinColumn;
588
+ const { name: targetColumnName } = joinTable.inverseJoinColumn;
589
+ // Process in batches to avoid MySQL query size limits
590
+ const publishedIdsChunks = chunkArray(publishedIds, 1000);
591
+ for (const publishedIdsChunk of publishedIdsChunks){
592
+ // Get relations where the source is a published entry (in batches)
593
+ const relationsQuery = trx(joinTable.name).select('*').whereIn(sourceColumnName, publishedIdsChunk);
594
+ applyJoinTableOrdering(relationsQuery, joinTable, sourceColumnName);
595
+ const relations = await relationsQuery;
596
+ if (relations.length === 0) {
597
+ continue;
598
+ }
599
+ // Create new relations pointing to draft entries
600
+ // Remove the 'id' field to avoid duplicate key errors
601
+ const newRelations = relations.map((relation)=>{
602
+ const newSourceId = getMappedValue(publishedToDraftMap, relation[sourceColumnName]);
603
+ const newTargetId = getMappedValue(publishedToDraftMap, relation[targetColumnName]);
604
+ if (!newSourceId || !newTargetId) {
605
+ // Skip if no mapping found
606
+ return null;
607
+ }
608
+ // Create new relation object without the 'id' field
609
+ const { id, ...relationWithoutId } = relation;
610
+ return {
611
+ ...relationWithoutId,
612
+ [sourceColumnName]: newSourceId,
613
+ [targetColumnName]: newTargetId
614
+ };
615
+ }).filter(Boolean);
616
+ if (newRelations.length > 0) {
617
+ await trx.batchInsert(joinTable.name, newRelations, 1000);
618
+ }
619
+ }
620
+ }
621
+ }
622
+ /**
623
+ * Copy relations from other content types that target this content type
624
+ */ async function copyRelationsFromOtherContentTypes({ trx, uid, publishedToDraftMap }) {
625
+ const targetMeta = strapi.db.metadata.get(uid);
626
+ if (!targetMeta) {
627
+ return;
628
+ }
629
+ const publishedTargetIds = Array.from(publishedToDraftMap.keys()).map((value)=>normalizeId(value)).filter((value)=>value != null);
630
+ if (publishedTargetIds.length === 0) {
631
+ return;
632
+ }
633
+ const draftTargetIds = Array.from(publishedToDraftMap.values()).map((value)=>normalizeId(value)).filter((value)=>value != null);
634
+ const models = [
635
+ ...Object.values(strapi.contentTypes),
636
+ ...Object.values(strapi.components)
637
+ ];
638
+ const buildRelationKey = (relation, sourceColumnName, targetId)=>{
639
+ const sourceId = normalizeId(relation[sourceColumnName]) ?? relation[sourceColumnName];
640
+ const fieldValue = 'field' in relation ? relation.field ?? '' : '';
641
+ const componentTypeValue = 'component_type' in relation ? relation.component_type ?? '' : '';
642
+ return `${sourceId ?? 'null'}::${targetId ?? 'null'}::${fieldValue}::${componentTypeValue}`;
643
+ };
644
+ for (const model of models){
645
+ const dbModel = strapi.db.metadata.get(model.uid);
646
+ if (!dbModel) {
647
+ continue;
648
+ }
649
+ const sourceHasDraftAndPublish = Boolean(model.options?.draftAndPublish);
650
+ for (const attribute of Object.values(dbModel.attributes)){
651
+ if (attribute.type !== 'relation' || attribute.target !== uid) {
652
+ continue;
653
+ }
654
+ const joinTable = attribute.joinTable;
655
+ if (!joinTable) {
656
+ continue;
657
+ }
658
+ // Component join tables are handled separately when cloning components.
659
+ if (joinTable.name.includes('_cmps')) {
660
+ continue;
661
+ }
662
+ // If the source content type also has draft/publish, its own cloning routine will recreate its relations.
663
+ if (sourceHasDraftAndPublish) {
664
+ continue;
665
+ }
666
+ const { name: sourceColumnName } = joinTable.joinColumn;
667
+ const { name: targetColumnName } = joinTable.inverseJoinColumn;
668
+ const existingKeys = new Set();
669
+ if (draftTargetIds.length > 0) {
670
+ const draftIdChunks = chunkArray(draftTargetIds, 1000);
671
+ for (const draftChunk of draftIdChunks){
672
+ const existingRelationsQuery = trx(joinTable.name).select('*').whereIn(targetColumnName, draftChunk);
673
+ applyJoinTableOrdering(existingRelationsQuery, joinTable, sourceColumnName);
674
+ const existingRelations = await existingRelationsQuery;
675
+ for (const relation of existingRelations){
676
+ existingKeys.add(buildRelationKey(relation, sourceColumnName, normalizeId(relation[targetColumnName])));
677
+ }
678
+ }
679
+ }
680
+ const publishedIdChunks = chunkArray(publishedTargetIds, 1000);
681
+ for (const chunk of publishedIdChunks){
682
+ const relationsQuery = trx(joinTable.name).select('*').whereIn(targetColumnName, chunk);
683
+ applyJoinTableOrdering(relationsQuery, joinTable, sourceColumnName);
684
+ const relations = await relationsQuery;
685
+ if (relations.length === 0) {
686
+ continue;
687
+ }
688
+ const newRelations = [];
689
+ for (const relation of relations){
690
+ const newTargetId = getMappedValue(publishedToDraftMap, relation[targetColumnName]);
691
+ if (!newTargetId) {
692
+ continue;
693
+ }
694
+ const key = buildRelationKey(relation, sourceColumnName, newTargetId);
695
+ if (existingKeys.has(key)) {
696
+ continue;
697
+ }
698
+ existingKeys.add(key);
699
+ const { id, ...relationWithoutId } = relation;
700
+ newRelations.push({
701
+ ...relationWithoutId,
702
+ [targetColumnName]: newTargetId
703
+ });
704
+ }
705
+ if (newRelations.length === 0) {
706
+ continue;
707
+ }
708
+ try {
709
+ await trx.batchInsert(joinTable.name, newRelations, 1000);
710
+ } catch (error) {
711
+ if (!isDuplicateEntryError(error)) {
712
+ throw error;
713
+ }
714
+ for (const relation of newRelations){
715
+ await insertRowWithDuplicateHandling(trx, joinTable.name, relation, {
716
+ reason: 'duplicate-draft-target-relation',
717
+ sourceUid: model.uid,
718
+ targetUid: uid
719
+ });
720
+ }
721
+ }
722
+ }
723
+ }
724
+ }
725
+ }
726
+ /**
727
+ * Copy relations from this content type that target other content types (category 3)
728
+ * Example: Article -> Categories/Tags
729
+ */ async function copyRelationsToOtherContentTypes({ trx, uid, publishedToDraftMap }) {
730
+ const meta = strapi.db.metadata.get(uid);
731
+ if (!meta) return;
732
+ const publishedIds = Array.from(publishedToDraftMap.keys());
733
+ // Cache target publishedToDraftMap to avoid duplicate calls for same target
734
+ const targetMapCache = new Map();
735
+ for (const attribute of Object.values(meta.attributes)){
736
+ if (attribute.type !== 'relation' || attribute.target === uid) {
737
+ continue;
738
+ }
739
+ const joinTable = attribute.joinTable;
740
+ if (!joinTable) {
741
+ continue;
742
+ }
743
+ // Skip component join tables - they are handled by copyComponentRelations
744
+ if (joinTable.name.includes('_cmps')) {
745
+ continue;
746
+ }
747
+ const { name: sourceColumnName } = joinTable.joinColumn;
748
+ const { name: targetColumnName } = joinTable.inverseJoinColumn;
749
+ // Get target content type's publishedToDraftMap if it has draft/publish (cached)
750
+ const targetUid = attribute.target;
751
+ if (!targetMapCache.has(targetUid)) {
752
+ const targetMeta = strapi.db.metadata.get(targetUid);
753
+ const targetMap = await buildPublishedToDraftMap({
754
+ trx,
755
+ uid: targetUid,
756
+ meta: targetMeta,
757
+ options: {
758
+ requireDraftAndPublish: true
759
+ }
760
+ });
761
+ targetMapCache.set(targetUid, targetMap);
762
+ }
763
+ const targetPublishedToDraftMap = targetMapCache.get(targetUid);
764
+ // Process in batches to avoid MySQL query size limits
765
+ const publishedIdsChunks = chunkArray(publishedIds, 1000);
766
+ for (const publishedIdsChunk of publishedIdsChunks){
767
+ // Get relations where the source is a published entry of our content type (in batches)
768
+ const relationsQuery = trx(joinTable.name).select('*').whereIn(sourceColumnName, publishedIdsChunk);
769
+ applyJoinTableOrdering(relationsQuery, joinTable, sourceColumnName);
770
+ const relations = await relationsQuery;
771
+ if (relations.length === 0) {
772
+ continue;
773
+ }
774
+ // Create new relations pointing to draft entries
775
+ // Remove the 'id' field to avoid duplicate key errors
776
+ const newRelations = relations.map((relation)=>{
777
+ const newSourceId = getMappedValue(publishedToDraftMap, relation[sourceColumnName]);
778
+ if (!newSourceId) {
779
+ return null;
780
+ }
781
+ // Map target ID to draft if target has draft/publish enabled
782
+ // This matches discard() behavior: drafts relate to drafts
783
+ let newTargetId = relation[targetColumnName];
784
+ if (targetPublishedToDraftMap) {
785
+ const mappedTargetId = getMappedValue(targetPublishedToDraftMap, relation[targetColumnName]);
786
+ if (mappedTargetId) {
787
+ newTargetId = mappedTargetId;
788
+ }
789
+ // If no draft mapping, keep published target (target might not have DP or was deleted)
790
+ }
791
+ // Create new relation object without the 'id' field
792
+ const { id, ...relationWithoutId } = relation;
793
+ return {
794
+ ...relationWithoutId,
795
+ [sourceColumnName]: newSourceId,
796
+ [targetColumnName]: newTargetId
797
+ };
798
+ }).filter(Boolean);
799
+ if (newRelations.length > 0) {
800
+ try {
801
+ await trx.batchInsert(joinTable.name, newRelations, 1000);
802
+ } catch (error) {
803
+ // If batch insert fails due to duplicates, try with conflict handling
804
+ if (error.code === '23505' || error.message?.includes('duplicate key')) {
805
+ const client = trx.client.config.client;
806
+ if (client === 'postgres' || client === 'pg') {
807
+ for (const relation of newRelations){
808
+ try {
809
+ await trx(joinTable.name).insert(relation).onConflict().ignore();
810
+ } catch (err) {
811
+ if (err.code !== '23505' && !err.message?.includes('duplicate key')) {
812
+ throw err;
813
+ }
814
+ }
815
+ }
816
+ } else {
817
+ throw error;
818
+ }
819
+ } else {
820
+ throw error;
821
+ }
822
+ }
823
+ }
824
+ }
825
+ }
826
+ }
827
+ /**
828
+ * Update JoinColumn relations (oneToOne, manyToOne foreign keys) to point to draft versions
829
+ * This matches discard() behavior: when creating drafts, foreign keys should point to draft targets
830
+ */ async function updateJoinColumnRelations({ db, trx, uid }) {
831
+ const meta = db.metadata.get(uid);
832
+ if (!meta) {
833
+ return;
834
+ }
835
+ // Create mapping from published entry ID to draft entry ID
836
+ const publishedToDraftMap = await buildPublishedToDraftMap({
837
+ trx,
838
+ uid,
839
+ meta
840
+ });
841
+ if (!publishedToDraftMap || publishedToDraftMap.size === 0) {
842
+ return;
843
+ }
844
+ // Cache target publishedToDraftMap to avoid duplicate calls for same target
845
+ const targetMapCache = new Map();
846
+ // Find all JoinColumn relations (oneToOne, manyToOne without joinTable)
847
+ for (const attribute of Object.values(meta.attributes)){
848
+ if (attribute.type !== 'relation') {
849
+ continue;
850
+ }
851
+ // Skip relations with joinTable (handled by copyRelationsToOtherContentTypes)
852
+ if (attribute.joinTable) {
853
+ continue;
854
+ }
855
+ // Only handle oneToOne and manyToOne relations that use joinColumn
856
+ const joinColumn = attribute.joinColumn;
857
+ if (!joinColumn) {
858
+ continue;
859
+ }
860
+ const targetUid = attribute.target;
861
+ const foreignKeyColumn = joinColumn.name;
862
+ // Get target content type's publishedToDraftMap if it has draft/publish (cached)
863
+ if (!targetMapCache.has(targetUid)) {
864
+ const targetMeta = strapi.db.metadata.get(targetUid);
865
+ const targetMap = await buildPublishedToDraftMap({
866
+ trx,
867
+ uid: targetUid,
868
+ meta: targetMeta,
869
+ options: {
870
+ requireDraftAndPublish: true
871
+ }
872
+ });
873
+ targetMapCache.set(targetUid, targetMap);
874
+ }
875
+ const targetPublishedToDraftMap = targetMapCache.get(targetUid);
876
+ if (!targetPublishedToDraftMap) {
877
+ continue;
878
+ }
879
+ const draftIds = Array.from(publishedToDraftMap.values());
880
+ if (draftIds.length === 0) {
881
+ continue;
882
+ }
883
+ const draftIdsChunks = chunkArray(draftIds, 1000);
884
+ for (const draftIdsChunk of draftIdsChunks){
885
+ // Get draft entries with their foreign key values
886
+ const draftEntriesWithFk = await trx(meta.tableName).select([
887
+ 'id',
888
+ foreignKeyColumn
889
+ ]).whereIn('id', draftIdsChunk).whereNotNull(foreignKeyColumn);
890
+ const updates = draftEntriesWithFk.reduce((acc, draftEntry)=>{
891
+ const publishedTargetIdRaw = draftEntry[foreignKeyColumn];
892
+ const normalizedPublishedTargetId = normalizeId(publishedTargetIdRaw);
893
+ const draftTargetId = normalizedPublishedTargetId == null ? undefined : targetPublishedToDraftMap.get(normalizedPublishedTargetId);
894
+ if (draftTargetId != null && normalizeId(draftTargetId) !== normalizedPublishedTargetId) {
895
+ acc.push({
896
+ id: draftEntry.id,
897
+ draftTargetId
898
+ });
899
+ }
900
+ return acc;
901
+ }, []);
902
+ if (updates.length === 0) {
903
+ continue;
904
+ }
905
+ const caseFragments = updates.map(()=>'WHEN ? THEN ?').join(' ');
906
+ const idsPlaceholders = updates.map(()=>'?').join(', ');
907
+ await trx.raw(`UPDATE ?? SET ?? = CASE ?? ${caseFragments} ELSE ?? END WHERE ?? IN (${idsPlaceholders})`, [
908
+ meta.tableName,
909
+ foreignKeyColumn,
910
+ 'id',
911
+ ...updates.flatMap(({ id, draftTargetId })=>[
912
+ id,
913
+ draftTargetId
914
+ ]),
915
+ foreignKeyColumn,
916
+ 'id',
917
+ ...updates.map(({ id })=>id)
918
+ ]);
919
+ }
920
+ }
921
+ }
922
+ /**
923
+ * Copy component relations from published entries to draft entries
924
+ */ async function copyComponentRelations({ trx, uid, publishedToDraftMap }) {
925
+ const meta = strapi.db.metadata.get(uid);
926
+ if (!meta) {
927
+ return;
928
+ }
929
+ // Get collectionName from content type schema (Meta only has tableName which may be shortened)
930
+ const contentType = strapi.contentTypes[uid];
931
+ const collectionName = contentType?.collectionName;
932
+ if (!collectionName) {
933
+ return;
934
+ }
935
+ const identifiers = strapi.db.metadata.identifiers;
936
+ const joinTableName = getComponentJoinTableName(collectionName, identifiers);
937
+ const entityIdColumn = getComponentJoinColumnEntityName(identifiers);
938
+ const componentIdColumn = getComponentJoinColumnInverseName(identifiers);
939
+ const componentTypeColumn = getComponentTypeColumn(identifiers);
940
+ const fieldColumn = identifiers.FIELD_COLUMN;
941
+ const orderColumn = identifiers.ORDER_COLUMN;
942
+ // Check if component join table exists
943
+ const hasTable = await trx.schema.hasTable(joinTableName);
944
+ if (!hasTable) {
945
+ return;
946
+ }
947
+ const publishedIds = Array.from(publishedToDraftMap.keys());
948
+ // Process in batches to avoid MySQL query size limits
949
+ const publishedIdsChunks = chunkArray(publishedIds, 1000);
950
+ for (const publishedIdsChunk of publishedIdsChunks){
951
+ // Get component relations for published entries
952
+ const componentRelations = await trx(joinTableName).select('*').whereIn(entityIdColumn, publishedIdsChunk);
953
+ if (componentRelations.length === 0) {
954
+ continue;
955
+ }
956
+ const componentCloneCache = new Map();
957
+ const componentTargetDraftMapCache = new Map();
958
+ const componentHierarchyCaches = {
959
+ parentInstanceCache: new Map(),
960
+ ancestorDpCache: new Map(),
961
+ parentDpCache: new Map()
962
+ };
963
+ // Filter component relations: only propagate if component's parent in the component hierarchy doesn't have draft/publish
964
+ // This matches discardDraft() behavior via shouldPropagateComponentRelationToNewVersion
965
+ //
966
+ // The logic: find what contains this component instance (could be a content type or another component).
967
+ // If it's a component, recursively check its parents. If any parent in the chain has DP, filter out the relation.
968
+ const filteredComponentRelations = await Promise.all(componentRelations.map(async (relation)=>{
969
+ const componentId = relation[componentIdColumn];
970
+ const componentType = relation[componentTypeColumn];
971
+ const entityId = relation[entityIdColumn];
972
+ const componentSchema = strapi.components[componentType];
973
+ if (!componentSchema) {
974
+ debug(`[copyComponentRelations] ${uid}: Keeping relation - unknown component type ${componentType} (entity: ${entityId}, componentId: ${componentId})`);
975
+ return relation;
976
+ }
977
+ const componentParent = await findComponentParentInstance(trx, identifiers, componentSchema.uid, componentId, uid, componentHierarchyCaches);
978
+ if (!componentParent) {
979
+ debug(`[copyComponentRelations] ${uid}: Keeping relation - component ${componentType} (id: ${componentId}) is directly on entity ${entityId} (no nested parent found)`);
980
+ return relation;
981
+ }
982
+ debug(`[copyComponentRelations] ${uid}: Component ${componentType} (id: ${componentId}, entity: ${entityId}) has parent in hierarchy: ${componentParent.uid} (parentId: ${componentParent.parentId})`);
983
+ const hasDPParent = await hasDraftPublishAncestorForParent(trx, identifiers, componentParent, componentHierarchyCaches);
984
+ if (hasDPParent) {
985
+ debug(`[copyComponentRelations] Filtering: component ${componentType} (id: ${componentId}, entity: ${entityId}) has DP parent in hierarchy (${componentParent.uid})`);
986
+ return null;
987
+ }
988
+ debug(`[copyComponentRelations] ${uid}: Keeping relation - component ${componentType} (id: ${componentId}, entity: ${entityId}) has no DP parent in hierarchy`);
989
+ return relation;
990
+ }));
991
+ // Filter out null values (filtered relations)
992
+ const relationsToProcess = filteredComponentRelations.filter(Boolean);
993
+ const filteredCount = componentRelations.length - relationsToProcess.length;
994
+ if (filteredCount > 0) {
995
+ debug(`[copyComponentRelations] ${uid}: Filtered ${filteredCount} of ${componentRelations.length} component relations (removed ${filteredCount} with DP parents)`);
996
+ }
997
+ // Create new component relations for draft entries
998
+ // Remove the 'id' field to avoid duplicate key errors
999
+ const mappedRelations = (await Promise.all(relationsToProcess.map(async (relation)=>{
1000
+ const newEntityId = getMappedValue(publishedToDraftMap, relation[entityIdColumn]);
1001
+ if (!newEntityId) {
1002
+ return null;
1003
+ }
1004
+ const componentId = relation[componentIdColumn];
1005
+ const componentType = relation[componentTypeColumn];
1006
+ const componentKey = `${componentId}:${newEntityId}`;
1007
+ let cloneMap = componentCloneCache.get(componentType);
1008
+ if (!cloneMap) {
1009
+ cloneMap = new Map();
1010
+ componentCloneCache.set(componentType, cloneMap);
1011
+ }
1012
+ let newComponentId = cloneMap.get(componentKey);
1013
+ if (!newComponentId) {
1014
+ newComponentId = await cloneComponentInstance({
1015
+ trx,
1016
+ componentUid: componentType,
1017
+ componentId: Number(componentId),
1018
+ parentUid: uid,
1019
+ parentPublishedToDraftMap: publishedToDraftMap,
1020
+ draftMapCache: componentTargetDraftMapCache
1021
+ });
1022
+ cloneMap.set(componentKey, newComponentId);
1023
+ }
1024
+ const { id, ...relationWithoutId } = relation;
1025
+ return {
1026
+ ...relationWithoutId,
1027
+ [entityIdColumn]: newEntityId,
1028
+ [componentIdColumn]: newComponentId
1029
+ };
1030
+ }))).filter(Boolean);
1031
+ // Deduplicate relations based on the unique constraint columns
1032
+ // This prevents duplicates within the same batch that could cause conflicts
1033
+ const uniqueKeyMap = new Map();
1034
+ for (const relation of mappedRelations){
1035
+ const uniqueKey = `${relation[entityIdColumn]}_${relation[componentIdColumn]}_${relation[fieldColumn]}_${relation[componentTypeColumn]}`;
1036
+ if (!uniqueKeyMap.has(uniqueKey)) {
1037
+ uniqueKeyMap.set(uniqueKey, relation);
1038
+ }
1039
+ }
1040
+ const deduplicatedRelations = Array.from(uniqueKeyMap.values());
1041
+ if (deduplicatedRelations.length === 0) {
1042
+ continue;
1043
+ }
1044
+ // Check which relations already exist in the database to avoid conflicts
1045
+ // We need to check all unique constraint columns (entity_id, cmp_id, field, component_type)
1046
+ const existingRelations = await trx(joinTableName).select([
1047
+ entityIdColumn,
1048
+ componentIdColumn,
1049
+ fieldColumn,
1050
+ componentTypeColumn
1051
+ ]).where((qb)=>{
1052
+ // Build OR conditions for each relation we want to check
1053
+ for (const relation of deduplicatedRelations){
1054
+ qb.orWhere((subQb)=>{
1055
+ subQb.where(entityIdColumn, relation[entityIdColumn]).where(componentIdColumn, relation[componentIdColumn]).where(fieldColumn, relation[fieldColumn]).where(componentTypeColumn, relation[componentTypeColumn]);
1056
+ });
1057
+ }
1058
+ });
1059
+ // Create a set of existing relation keys for fast lookup
1060
+ const existingKeys = new Set();
1061
+ for (const existing of existingRelations){
1062
+ const key = `${existing[entityIdColumn]}_${existing[componentIdColumn]}_${existing[fieldColumn]}_${existing[componentTypeColumn]}`;
1063
+ existingKeys.add(key);
1064
+ }
1065
+ // Filter out relations that already exist
1066
+ const newComponentRelations = deduplicatedRelations.filter((relation)=>{
1067
+ const key = `${relation[entityIdColumn]}_${relation[componentIdColumn]}_${relation[fieldColumn]}_${relation[componentTypeColumn]}`;
1068
+ return !existingKeys.has(key);
1069
+ });
1070
+ if (newComponentRelations.length > 0) {
1071
+ // Insert component relations while surfacing unexpected constraint issues.
1072
+ // Use INSERT ... ON CONFLICT DO NOTHING (PostgreSQL) or catch duplicate key errors explicitly for other clients.
1073
+ const client = trx.client.config.client;
1074
+ if (client === 'postgres' || client === 'pg') {
1075
+ // PostgreSQL: Insert one at a time with ON CONFLICT DO NOTHING
1076
+ // Use raw SQL for more reliable conflict handling
1077
+ let insertedCount = 0;
1078
+ let skippedCount = 0;
1079
+ for (const relation of newComponentRelations){
1080
+ try {
1081
+ // Use raw SQL - ?? is for identifiers, ? is for values in Knex
1082
+ const orderValue = relation[orderColumn] ?? null;
1083
+ await trx.raw(`INSERT INTO ?? (??, ??, ??, ??, ??) VALUES (?, ?, ?, ?, ?)
1084
+ ON CONFLICT (??, ??, ??, ??) DO NOTHING`, [
1085
+ joinTableName,
1086
+ entityIdColumn,
1087
+ componentIdColumn,
1088
+ fieldColumn,
1089
+ componentTypeColumn,
1090
+ orderColumn,
1091
+ relation[entityIdColumn],
1092
+ relation[componentIdColumn],
1093
+ relation[fieldColumn],
1094
+ relation[componentTypeColumn],
1095
+ orderValue,
1096
+ entityIdColumn,
1097
+ componentIdColumn,
1098
+ fieldColumn,
1099
+ componentTypeColumn
1100
+ ]);
1101
+ insertedCount += 1;
1102
+ } catch (error) {
1103
+ // Ignore duplicate key errors (PostgreSQL error code 23505)
1104
+ // This can happen if the record already exists in the database
1105
+ if (error.code !== '23505' && !error.message?.includes('duplicate key')) {
1106
+ throw error;
1107
+ } else {
1108
+ skippedCount += 1;
1109
+ }
1110
+ }
1111
+ }
1112
+ if (insertedCount > 0 || skippedCount > 0) {
1113
+ debug(`[copyComponentRelations] ${uid}: Inserted ${insertedCount} component relations, skipped ${skippedCount} duplicates`);
1114
+ }
1115
+ } else if (client === 'mysql' || client === 'mysql2') {
1116
+ try {
1117
+ await trx(joinTableName).insert(newComponentRelations);
1118
+ } catch (error) {
1119
+ if (error.code !== 'ER_DUP_ENTRY') {
1120
+ throw error;
1121
+ }
1122
+ for (const relation of newComponentRelations){
1123
+ try {
1124
+ await trx(joinTableName).insert(relation);
1125
+ } catch (err) {
1126
+ if (err.code !== 'ER_DUP_ENTRY') {
1127
+ throw err;
1128
+ }
1129
+ }
1130
+ }
1131
+ }
1132
+ } else {
1133
+ // SQLite: Try to insert and ignore errors
1134
+ for (const relation of newComponentRelations){
1135
+ try {
1136
+ await trx(joinTableName).insert(relation);
1137
+ } catch (error) {
1138
+ // Ignore duplicate key errors
1139
+ if (error.code !== 'SQLITE_CONSTRAINT_UNIQUE' && error.code !== '23505') {
1140
+ throw error;
1141
+ }
1142
+ }
1143
+ }
1144
+ }
1145
+ }
1146
+ }
1147
+ }
1148
+ /**
1149
+ * 2 pass migration to create the draft entries for all the published entries.
1150
+ * And then copy relations directly using database queries.
1151
+ */ const migrateUp = async (trx, db)=>{
1152
+ strapi.log.info('[discard-drafts] Migration started');
1153
+ const dpModels = [];
1154
+ for (const meta of db.metadata.values()){
1155
+ const hasDP = await hasDraftAndPublish(trx, meta);
1156
+ if (hasDP) {
1157
+ dpModels.push(meta);
1158
+ }
1159
+ }
1160
+ debug(`Found ${dpModels.length} draft/publish content types to process`);
1161
+ /**
1162
+ * Create plain draft entries for all the entries that were published.
1163
+ */ strapi.log.info('[discard-drafts] Stage 1/3 – cloning published entries into draft rows');
1164
+ for (const model of dpModels){
1165
+ debug(` • cloning scalars for ${model.uid}`);
1166
+ await copyPublishedEntriesToDraft({
1167
+ db,
1168
+ trx,
1169
+ uid: model.uid
1170
+ });
1171
+ }
1172
+ strapi.log.info('[discard-drafts] Stage 1/3 complete');
1173
+ /**
1174
+ * Copy relations from published entries to draft entries using direct database queries.
1175
+ * This is much more efficient than calling discardDraft for each entry.
1176
+ */ strapi.log.info('[discard-drafts] Stage 2/3 – copying relations and components to drafts');
1177
+ for (const model of dpModels){
1178
+ debug(` • copying relations for ${model.uid}`);
1179
+ await copyRelationsToDrafts({
1180
+ db,
1181
+ trx,
1182
+ uid: model.uid
1183
+ });
1184
+ }
1185
+ strapi.log.info('[discard-drafts] Stage 2/3 complete');
1186
+ /**
1187
+ * Update JoinColumn relations (foreign keys) to point to draft versions
1188
+ * This matches discard() behavior: drafts relate to drafts
1189
+ */ strapi.log.info('[discard-drafts] Stage 3/3 – updating foreign key references to draft targets');
1190
+ for (const model of dpModels){
1191
+ debug(` • updating join columns for ${model.uid}`);
1192
+ await updateJoinColumnRelations({
1193
+ db,
1194
+ trx,
1195
+ uid: model.uid
1196
+ });
1197
+ }
1198
+ strapi.log.info('[discard-drafts] Stage 3/3 complete');
1199
+ strapi.log.info('[discard-drafts] Migration completed successfully');
1200
+ };
59
1201
  /**
60
1202
  * Load a batch of versions to discard.
61
1203
  *
@@ -93,64 +1235,6 @@ import { createDocumentService } from '../../services/document-service/index.mjs
93
1235
  yield batch;
94
1236
  }
95
1237
  }
96
- /**
97
- * 2 pass migration to create the draft entries for all the published entries.
98
- * And then discard the drafts to copy the relations.
99
- */ const migrateUp = async (trx, db)=>{
100
- const dpModels = [];
101
- for (const meta of db.metadata.values()){
102
- const hasDP = await hasDraftAndPublish(trx, meta);
103
- if (hasDP) {
104
- dpModels.push(meta);
105
- }
106
- }
107
- /**
108
- * Create plain draft entries for all the entries that were published.
109
- */ for (const model of dpModels){
110
- await copyPublishedEntriesToDraft({
111
- db,
112
- trx,
113
- uid: model.uid
114
- });
115
- }
116
- /**
117
- * Discard the drafts will copy the relations from the published entries to the newly created drafts.
118
- *
119
- * Load a batch of entries (batched to prevent loading millions of rows at once ),
120
- * and discard them using the document service.
121
- *
122
- * NOTE: This is using a custom document service without any validations,
123
- * to prevent the migration from failing if users already had invalid data in V4.
124
- * E.g. @see https://github.com/strapi/strapi/issues/21583
125
- */ const documentService = createDocumentService(strapi, {
126
- async validateEntityCreation (_, data) {
127
- return data;
128
- },
129
- async validateEntityUpdate (_, data) {
130
- // Data can be partially empty on partial updates
131
- // This migration doesn't trigger any update (or partial update),
132
- // so it's safe to return the data as is.
133
- return data;
134
- }
135
- });
136
- for (const model of dpModels){
137
- const discardDraft = async (entry)=>documentService(model.uid).discardDraft({
138
- documentId: entry.documentId,
139
- locale: entry.locale
140
- });
141
- for await (const batch of getBatchToDiscard({
142
- db,
143
- trx,
144
- uid: model.uid
145
- })){
146
- // NOTE: concurrency had to be disabled to prevent a race condition with self-references
147
- // TODO: improve performance in a safe way
148
- await async.map(batch, discardDraft, {
149
- concurrency: 1
150
- });
151
- }
152
- }
153
- };
154
1238
  const discardDocumentDrafts = {
155
1239
  name: 'core::5.0.0-discard-drafts',
156
1240
  async up (trx, db) {