strapi-content-sync-pro 1.0.2 → 1.0.4

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 (36) hide show
  1. package/README.md +67 -18
  2. package/admin/src/components/BulkTransferTab.jsx +880 -0
  3. package/admin/src/components/ConfigTab.jsx +25 -4
  4. package/admin/src/components/HelpTab.jsx +201 -15
  5. package/admin/src/components/MediaTab.jsx +7 -0
  6. package/admin/src/components/StatsTab.jsx +470 -0
  7. package/admin/src/components/SyncProfilesTab.jsx +63 -5
  8. package/admin/src/components/SyncTab.jsx +53 -7
  9. package/admin/src/pages/App/index.jsx +15 -1
  10. package/docs/clipchamp-screen-recording-script.md +0 -0
  11. package/docs/production-readiness-status.md +34 -0
  12. package/docs/production-readiness-test-matrix.md +151 -0
  13. package/docs/test-environments-setup-legacy.txt +60 -0
  14. package/package.json +13 -4
  15. package/server/src/content-types/index.js +2 -0
  16. package/server/src/content-types/sync-run-report/schema.json +26 -0
  17. package/server/src/controllers/bulk-transfer.js +141 -0
  18. package/server/src/controllers/config.js +48 -5
  19. package/server/src/controllers/index.js +4 -0
  20. package/server/src/controllers/sync-log.js +6 -0
  21. package/server/src/controllers/sync-media.js +19 -0
  22. package/server/src/controllers/sync-stats.js +51 -0
  23. package/server/src/controllers/sync.js +9 -3
  24. package/server/src/routes/index.js +28 -0
  25. package/server/src/services/bulk-transfer.js +837 -0
  26. package/server/src/services/config.js +18 -2
  27. package/server/src/services/index.js +4 -0
  28. package/server/src/services/sync-execution.js +102 -5
  29. package/server/src/services/sync-log.js +36 -0
  30. package/server/src/services/sync-media.js +224 -1
  31. package/server/src/services/sync-profiles.js +92 -4
  32. package/server/src/services/sync-stats.js +353 -0
  33. package/server/src/services/sync.js +323 -101
  34. package/server/src/utils/applier.js +120 -13
  35. package/server/src/utils/comparator.js +22 -6
  36. package/server/src/utils/fetcher.js +11 -2
@@ -38,6 +38,29 @@ module.exports = ({ strapi }) => {
38
38
  const VALID_DIRECTIONS = ['push', 'pull', 'both', 'none'];
39
39
  const VALID_CONFLICT_STRATEGIES = ['latest', 'local_wins', 'remote_wins'];
40
40
 
41
+ async function getSyncMode() {
42
+ const configService = strapi.plugin('strapi-content-sync-pro').service('config');
43
+ const config = await configService.getConfig({ safe: false });
44
+ return config?.syncMode || 'paired';
45
+ }
46
+
47
+ function normalizeProfileForMode(profile, syncMode) {
48
+ if (syncMode !== 'single_side') return profile;
49
+
50
+ const next = { ...profile };
51
+ next.direction = 'pull';
52
+
53
+ if (!next.isSimple && Array.isArray(next.fieldPolicies)) {
54
+ next.fieldPolicies = next.fieldPolicies.map((fp) => {
55
+ if (fp.direction === 'push') return { ...fp, direction: 'pull' };
56
+ if (fp.direction === 'both') return { ...fp, direction: 'pull' };
57
+ return fp;
58
+ });
59
+ }
60
+
61
+ return next;
62
+ }
63
+
41
64
  return {
42
65
  /**
43
66
  * Get all sync profiles
@@ -45,7 +68,33 @@ module.exports = ({ strapi }) => {
45
68
  async getProfiles() {
46
69
  const store = getStore();
47
70
  const data = await store.get({ key: STORE_KEY });
48
- return data || [];
71
+ const profiles = data || [];
72
+ const syncMode = await getSyncMode();
73
+ if (syncMode !== 'single_side') return profiles;
74
+
75
+ let changed = false;
76
+ const normalized = profiles.map((p) => {
77
+ if (p.direction === 'pull') return p;
78
+ changed = true;
79
+ return {
80
+ ...p,
81
+ direction: 'pull',
82
+ syncDeletions: !!p.syncDeletions,
83
+ fieldPolicies: Array.isArray(p.fieldPolicies)
84
+ ? p.fieldPolicies.map((fp) => ({
85
+ ...fp,
86
+ direction: fp.direction === 'none' ? 'none' : 'pull',
87
+ }))
88
+ : p.fieldPolicies,
89
+ updatedAt: new Date().toISOString(),
90
+ };
91
+ });
92
+
93
+ if (changed) {
94
+ await store.set({ key: STORE_KEY, value: normalized });
95
+ }
96
+
97
+ return normalized;
49
98
  },
50
99
 
51
100
  /**
@@ -53,7 +102,10 @@ module.exports = ({ strapi }) => {
53
102
  */
54
103
  async getProfile(id) {
55
104
  const profiles = await this.getProfiles();
56
- return profiles.find((p) => p.id === id) || null;
105
+ const profile = profiles.find((p) => p.id === id) || null;
106
+ if (!profile) return null;
107
+ const syncMode = await getSyncMode();
108
+ return normalizeProfileForMode(profile, syncMode);
57
109
  },
58
110
 
59
111
  /**
@@ -61,7 +113,11 @@ module.exports = ({ strapi }) => {
61
113
  */
62
114
  async getActiveProfileForContentType(contentTypeUid) {
63
115
  const profiles = await this.getProfiles();
64
- return profiles.find((p) => p.contentType === contentTypeUid && p.isActive) || null;
116
+ const active = profiles.find((p) => p.contentType === contentTypeUid && p.isActive) || null;
117
+ if (!active) return null;
118
+
119
+ const syncMode = await getSyncMode();
120
+ return normalizeProfileForMode(active, syncMode);
65
121
  },
66
122
 
67
123
  /**
@@ -69,7 +125,10 @@ module.exports = ({ strapi }) => {
69
125
  */
70
126
  async getProfilesForContentType(contentTypeUid) {
71
127
  const profiles = await this.getProfiles();
72
- return profiles.filter((p) => p.contentType === contentTypeUid);
128
+ const syncMode = await getSyncMode();
129
+ return profiles
130
+ .filter((p) => p.contentType === contentTypeUid)
131
+ .map((p) => normalizeProfileForMode(p, syncMode));
73
132
  },
74
133
 
75
134
  /**
@@ -91,6 +150,7 @@ module.exports = ({ strapi }) => {
91
150
  contentType: contentTypeUid,
92
151
  direction: 'push',
93
152
  conflictStrategy: 'local_wins',
153
+ syncDeletions: false,
94
154
  isActive: false,
95
155
  isSimple: true,
96
156
  fieldPolicies: [],
@@ -100,6 +160,7 @@ module.exports = ({ strapi }) => {
100
160
  contentType: contentTypeUid,
101
161
  direction: 'pull',
102
162
  conflictStrategy: 'remote_wins',
163
+ syncDeletions: false,
103
164
  isActive: false,
104
165
  isSimple: true,
105
166
  fieldPolicies: [],
@@ -109,6 +170,7 @@ module.exports = ({ strapi }) => {
109
170
  contentType: contentTypeUid,
110
171
  direction: 'both',
111
172
  conflictStrategy: 'latest',
173
+ syncDeletions: false,
112
174
  isActive: true, // Default active profile
113
175
  isSimple: true,
114
176
  fieldPolicies: [],
@@ -166,6 +228,7 @@ module.exports = ({ strapi }) => {
166
228
  contentType: profileData.contentType,
167
229
  direction: profileData.direction || 'both',
168
230
  conflictStrategy: profileData.conflictStrategy || 'latest',
231
+ syncDeletions: !!profileData.syncDeletions,
169
232
  isActive: profileData.isActive || false,
170
233
  isSimple: profileData.isSimple !== false, // Default to simple mode
171
234
  fieldPolicies: (profileData.fieldPolicies || []).map((fp) => ({
@@ -176,6 +239,17 @@ module.exports = ({ strapi }) => {
176
239
  updatedAt: new Date().toISOString(),
177
240
  };
178
241
 
242
+ const syncMode = await getSyncMode();
243
+ if (syncMode === 'single_side') {
244
+ newProfile.direction = 'pull';
245
+ if (!newProfile.isSimple && Array.isArray(newProfile.fieldPolicies)) {
246
+ newProfile.fieldPolicies = newProfile.fieldPolicies.map((fp) => ({
247
+ ...fp,
248
+ direction: fp.direction === 'none' ? 'none' : 'pull',
249
+ }));
250
+ }
251
+ }
252
+
179
253
  // If this profile is set as active, deactivate others for same content type
180
254
  if (newProfile.isActive) {
181
255
  profiles.forEach((p) => {
@@ -238,11 +312,17 @@ module.exports = ({ strapi }) => {
238
312
  const updatedProfile = {
239
313
  ...profiles[index],
240
314
  ...updates,
315
+ syncDeletions: updates.syncDeletions !== undefined ? !!updates.syncDeletions : profiles[index].syncDeletions,
241
316
  id: profiles[index].id, // prevent id change
242
317
  createdAt: profiles[index].createdAt, // preserve creation date
243
318
  updatedAt: new Date().toISOString(),
244
319
  };
245
320
 
321
+ const syncMode = await getSyncMode();
322
+ if (syncMode === 'single_side') {
323
+ updatedProfile.direction = 'pull';
324
+ }
325
+
246
326
  if (updates.fieldPolicies) {
247
327
  updatedProfile.fieldPolicies = updates.fieldPolicies.map((fp) => ({
248
328
  field: fp.field,
@@ -250,6 +330,13 @@ module.exports = ({ strapi }) => {
250
330
  }));
251
331
  }
252
332
 
333
+ if (syncMode === 'single_side' && !updatedProfile.isSimple && Array.isArray(updatedProfile.fieldPolicies)) {
334
+ updatedProfile.fieldPolicies = updatedProfile.fieldPolicies.map((fp) => ({
335
+ ...fp,
336
+ direction: fp.direction === 'none' ? 'none' : 'pull',
337
+ }));
338
+ }
339
+
253
340
  profiles[index] = updatedProfile;
254
341
  await store.set({ key: STORE_KEY, value: profiles });
255
342
 
@@ -305,6 +392,7 @@ module.exports = ({ strapi }) => {
305
392
  return this.createProfile({
306
393
  ...presetConfig,
307
394
  contentType: contentTypeUid,
395
+ syncDeletions: false,
308
396
  isSimple: true,
309
397
  isActive: false,
310
398
  fieldPolicies: [],
@@ -0,0 +1,353 @@
1
+ 'use strict';
2
+
3
+ const { uidToPluralEndpoint } = require('../utils/fetcher');
4
+
5
+ const RUN_REPORT_UID = 'plugin::strapi-content-sync-pro.sync-run-report';
6
+
7
+ module.exports = ({ strapi }) => {
8
+ function plugin() {
9
+ return strapi.plugin('strapi-content-sync-pro');
10
+ }
11
+
12
+ // Strapi upload's polymorphic join table name differs across versions
13
+ // (`files_related_morphs` on older builds, `files_related_mph` on v5).
14
+ // Resolve it from ORM metadata so stats work on both.
15
+ let _morphTableCache = null;
16
+ function resolveMorphTable() {
17
+ if (_morphTableCache) return _morphTableCache;
18
+ const candidates = [];
19
+ try {
20
+ const attr = strapi.db?.metadata?.get?.('plugin::upload.file')?.attributes?.related;
21
+ if (attr?.joinTable?.name) candidates.push(attr.joinTable.name);
22
+ if (attr?.pivotTable) candidates.push(attr.pivotTable);
23
+ } catch {
24
+ // ignore — fall back below
25
+ }
26
+ candidates.push('files_related_mph', 'files_related_morphs');
27
+ _morphTableCache = candidates.find((n) => !!n) || 'files_related_mph';
28
+ return _morphTableCache;
29
+ }
30
+
31
+ async function getEnabledContentTypes() {
32
+ const syncConfig = await plugin().service('syncConfig').getSyncConfig();
33
+ return (syncConfig.contentTypes || []).filter((ct) => ct.enabled).map((ct) => ct.uid);
34
+ }
35
+
36
+ async function getMediaStatsLocal() {
37
+ const [count, newest] = await Promise.all([
38
+ strapi.db.query('plugin::upload.file').count(),
39
+ strapi.db.query('plugin::upload.file').findMany({
40
+ orderBy: { updatedAt: 'desc' },
41
+ limit: 1,
42
+ select: ['updatedAt'],
43
+ }),
44
+ ]);
45
+
46
+ const morphTable = resolveMorphTable();
47
+ const [morphCount, newestMorph] = await Promise.all([
48
+ strapi.db.connection(morphTable).count({ id: 'id' }).first().then((r) => Number(r?.id || 0)).catch(() => 0),
49
+ strapi.db.connection(morphTable).orderBy('id', 'desc').first().catch(() => null),
50
+ ]);
51
+
52
+ return {
53
+ fileCount: Number(count || 0),
54
+ fileNewestUpdatedAt: newest?.[0]?.updatedAt || null,
55
+ morphCount: Number(morphCount || 0),
56
+ morphNewestUpdatedAt: newestMorph?.created_at || newestMorph?.updated_at || null,
57
+ error: null,
58
+ };
59
+ }
60
+
61
+ async function getMediaStatsRemote(remoteConfig) {
62
+ const { baseUrl, apiToken } = remoteConfig || {};
63
+ if (!baseUrl || !apiToken) {
64
+ return {
65
+ fileCount: null,
66
+ fileNewestUpdatedAt: null,
67
+ morphCount: null,
68
+ morphNewestUpdatedAt: null,
69
+ error: 'Remote server is not configured',
70
+ };
71
+ }
72
+
73
+ try {
74
+ const countUrl = new URL('/api/upload/files', baseUrl);
75
+ countUrl.searchParams.set('pagination[page]', '1');
76
+ countUrl.searchParams.set('pagination[pageSize]', '1');
77
+ countUrl.searchParams.set('fields[0]', 'updatedAt');
78
+ const countRes = await fetch(countUrl.toString(), {
79
+ method: 'GET',
80
+ headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
81
+ });
82
+ if (!countRes.ok) {
83
+ return { fileCount: null, fileNewestUpdatedAt: null, morphCount: null, morphNewestUpdatedAt: null, error: `Remote media fetch failed (${countRes.status})` };
84
+ }
85
+ const countBody = await countRes.json();
86
+ const fileCount = countBody?.meta?.pagination?.total ?? null;
87
+
88
+ const newestUrl = new URL('/api/upload/files', baseUrl);
89
+ newestUrl.searchParams.set('pagination[page]', '1');
90
+ newestUrl.searchParams.set('pagination[pageSize]', '1');
91
+ newestUrl.searchParams.set('sort', 'updatedAt:desc');
92
+ newestUrl.searchParams.set('fields[0]', 'updatedAt');
93
+ const newestRes = await fetch(newestUrl.toString(), {
94
+ method: 'GET',
95
+ headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
96
+ });
97
+ if (!newestRes.ok) {
98
+ return { fileCount, fileNewestUpdatedAt: null, morphCount: null, morphNewestUpdatedAt: null, error: `Remote media newest fetch failed (${newestRes.status})` };
99
+ }
100
+
101
+ const newestBody = await newestRes.json();
102
+ const fileNewestUpdatedAt = newestBody?.data?.[0]?.updatedAt || null;
103
+
104
+ // Morph rows are internal DB relations; not exposed by core upload REST.
105
+ return {
106
+ fileCount,
107
+ fileNewestUpdatedAt,
108
+ morphCount: null,
109
+ morphNewestUpdatedAt: null,
110
+ error: null,
111
+ };
112
+ } catch (err) {
113
+ return { fileCount: null, fileNewestUpdatedAt: null, morphCount: null, morphNewestUpdatedAt: null, error: err.message };
114
+ }
115
+ }
116
+
117
+ async function fetchRemoteStats(remoteConfig, uid) {
118
+ const { baseUrl, apiToken } = remoteConfig || {};
119
+ if (!baseUrl || !apiToken) {
120
+ return { count: null, newestUpdatedAt: null, error: 'Remote server is not configured' };
121
+ }
122
+
123
+ const endpoint = uidToPluralEndpoint(uid);
124
+
125
+ try {
126
+ const countUrl = new URL(`/api/${endpoint}`, baseUrl);
127
+ countUrl.searchParams.set('pagination[page]', '1');
128
+ countUrl.searchParams.set('pagination[pageSize]', '1');
129
+ countUrl.searchParams.set('fields[0]', 'updatedAt');
130
+ const countRes = await fetch(countUrl.toString(), {
131
+ method: 'GET',
132
+ headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
133
+ });
134
+ if (!countRes.ok) {
135
+ return { count: null, newestUpdatedAt: null, error: `Remote count fetch failed (${countRes.status})` };
136
+ }
137
+ const countBody = await countRes.json();
138
+ const count = countBody?.meta?.pagination?.total ?? null;
139
+
140
+ const newestUrl = new URL(`/api/${endpoint}`, baseUrl);
141
+ newestUrl.searchParams.set('pagination[page]', '1');
142
+ newestUrl.searchParams.set('pagination[pageSize]', '1');
143
+ newestUrl.searchParams.set('sort', 'updatedAt:desc');
144
+ newestUrl.searchParams.set('fields[0]', 'updatedAt');
145
+ const newestRes = await fetch(newestUrl.toString(), {
146
+ method: 'GET',
147
+ headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
148
+ });
149
+ if (!newestRes.ok) {
150
+ return { count, newestUpdatedAt: null, error: `Remote newest fetch failed (${newestRes.status})` };
151
+ }
152
+
153
+ const newestBody = await newestRes.json();
154
+ const newestUpdatedAt = newestBody?.data?.[0]?.updatedAt || null;
155
+ return { count, newestUpdatedAt, error: null };
156
+ } catch (err) {
157
+ return { count: null, newestUpdatedAt: null, error: err.message };
158
+ }
159
+ }
160
+
161
+ function whereNewest(localTs, remoteTs) {
162
+ if (!localTs && !remoteTs) return 'equal';
163
+ if (localTs && !remoteTs) return 'local';
164
+ if (!localTs && remoteTs) return 'remote';
165
+ const l = new Date(localTs).getTime();
166
+ const r = new Date(remoteTs).getTime();
167
+ if (l === r) return 'equal';
168
+ return l > r ? 'local' : 'remote';
169
+ }
170
+
171
+ return {
172
+ async collectSnapshot({ contentTypes } = {}) {
173
+ const configService = plugin().service('config');
174
+ const remoteConfig = await configService.getConfig({ safe: false });
175
+ const targets = Array.isArray(contentTypes) && contentTypes.length > 0
176
+ ? contentTypes
177
+ : await getEnabledContentTypes();
178
+
179
+ const rows = [];
180
+ for (const uid of targets) {
181
+ let localCount = null;
182
+ let localNewestUpdatedAt = null;
183
+ let localError = null;
184
+ try {
185
+ localCount = await strapi.documents(uid).count({});
186
+ const newest = await strapi.documents(uid).findMany({
187
+ sort: { updatedAt: 'desc' },
188
+ limit: 1,
189
+ fields: ['updatedAt'],
190
+ });
191
+ localNewestUpdatedAt = newest?.[0]?.updatedAt || null;
192
+ } catch (err) {
193
+ localError = err.message;
194
+ }
195
+
196
+ const remote = await fetchRemoteStats(remoteConfig, uid);
197
+ rows.push({
198
+ uid,
199
+ type: 'content',
200
+ localCount,
201
+ remoteCount: remote.count,
202
+ localNewestUpdatedAt,
203
+ remoteNewestUpdatedAt: remote.newestUpdatedAt,
204
+ newestSide: whereNewest(localNewestUpdatedAt, remote.newestUpdatedAt),
205
+ localError,
206
+ remoteError: remote.error,
207
+ });
208
+ }
209
+
210
+ const localMedia = await getMediaStatsLocal();
211
+ const remoteMedia = await getMediaStatsRemote(remoteConfig);
212
+ rows.push({
213
+ uid: 'plugin::upload.file',
214
+ type: 'media_file',
215
+ localCount: localMedia.fileCount,
216
+ remoteCount: remoteMedia.fileCount,
217
+ localNewestUpdatedAt: localMedia.fileNewestUpdatedAt,
218
+ remoteNewestUpdatedAt: remoteMedia.fileNewestUpdatedAt,
219
+ newestSide: whereNewest(localMedia.fileNewestUpdatedAt, remoteMedia.fileNewestUpdatedAt),
220
+ localError: localMedia.error,
221
+ remoteError: remoteMedia.error,
222
+ });
223
+
224
+ rows.push({
225
+ uid: 'upload.morph',
226
+ type: 'media_morph',
227
+ localCount: localMedia.morphCount,
228
+ remoteCount: remoteMedia.morphCount,
229
+ localNewestUpdatedAt: localMedia.morphNewestUpdatedAt,
230
+ remoteNewestUpdatedAt: remoteMedia.morphNewestUpdatedAt,
231
+ newestSide: whereNewest(localMedia.morphNewestUpdatedAt, remoteMedia.morphNewestUpdatedAt),
232
+ localError: localMedia.error,
233
+ remoteError: remoteMedia.morphCount === null ? 'Remote morph stats are unavailable via public API' : remoteMedia.error,
234
+ });
235
+
236
+ return {
237
+ generatedAt: new Date().toISOString(),
238
+ contentTypes: targets,
239
+ rows,
240
+ };
241
+ },
242
+
243
+ async createRunReport({ runType = 'content', trigger = 'manual', contentTypes = [], beforeStats = null }) {
244
+ const startedAt = new Date().toISOString();
245
+ const baseBefore = beforeStats || await this.collectSnapshot({ contentTypes });
246
+ const doc = await strapi.documents(RUN_REPORT_UID).create({
247
+ data: {
248
+ runType,
249
+ trigger,
250
+ status: 'running',
251
+ startedAt,
252
+ completedAt: null,
253
+ contentTypes: baseBefore.contentTypes,
254
+ beforeStats: baseBefore,
255
+ afterStats: null,
256
+ summary: null,
257
+ error: null,
258
+ },
259
+ });
260
+ return { reportId: doc.documentId, startedAt, beforeStats: baseBefore };
261
+ },
262
+
263
+ async completeRunReport(reportId, { status = 'success', summary = null, error = null } = {}) {
264
+ if (!reportId) return null;
265
+ const report = await strapi.documents(RUN_REPORT_UID).findFirst({
266
+ filters: { documentId: reportId },
267
+ fields: ['documentId'],
268
+ });
269
+ if (!report?.documentId) return null;
270
+
271
+ const before = await strapi.documents(RUN_REPORT_UID).findFirst({
272
+ filters: { documentId: reportId },
273
+ fields: ['contentTypes'],
274
+ });
275
+
276
+ const afterStats = await this.collectSnapshot({ contentTypes: before?.contentTypes || [] });
277
+ await strapi.documents(RUN_REPORT_UID).update({
278
+ documentId: report.documentId,
279
+ data: {
280
+ status,
281
+ completedAt: new Date().toISOString(),
282
+ afterStats,
283
+ summary: summary || null,
284
+ error: error || null,
285
+ },
286
+ });
287
+ return { reportId: report.documentId, status };
288
+ },
289
+
290
+ async getLatestSnapshot() {
291
+ return this.collectSnapshot({});
292
+ },
293
+
294
+ async getReports({ page = 1, pageSize = 10 } = {}) {
295
+ const start = (page - 1) * pageSize;
296
+ const [data, total] = await Promise.all([
297
+ strapi.documents(RUN_REPORT_UID).findMany({
298
+ sort: { createdAt: 'desc' },
299
+ start,
300
+ limit: pageSize,
301
+ }),
302
+ strapi.documents(RUN_REPORT_UID).count(),
303
+ ]);
304
+
305
+ return {
306
+ data,
307
+ meta: {
308
+ pagination: {
309
+ page,
310
+ pageSize,
311
+ pageCount: Math.ceil(total / pageSize),
312
+ total,
313
+ },
314
+ },
315
+ };
316
+ },
317
+
318
+ async clearReports() {
319
+ const existing = await strapi.documents(RUN_REPORT_UID).findMany({
320
+ fields: ['documentId'],
321
+ sort: { createdAt: 'desc' },
322
+ limit: 10000,
323
+ });
324
+
325
+ for (const report of existing) {
326
+ if (!report?.documentId) continue;
327
+ await strapi.documents(RUN_REPORT_UID).delete({ documentId: report.documentId });
328
+ }
329
+
330
+ return { deleted: existing.length };
331
+ },
332
+
333
+ async applyRetention({ maxReports = 200 } = {}) {
334
+ const safeMax = Math.max(10, Number(maxReports) || 200);
335
+ const total = await strapi.documents(RUN_REPORT_UID).count();
336
+ if (total <= safeMax) return { pruned: 0, remaining: total };
337
+
338
+ const excess = total - safeMax;
339
+ const oldReports = await strapi.documents(RUN_REPORT_UID).findMany({
340
+ fields: ['documentId'],
341
+ sort: { createdAt: 'asc' },
342
+ limit: excess,
343
+ });
344
+
345
+ for (const report of oldReports) {
346
+ if (!report?.documentId) continue;
347
+ await strapi.documents(RUN_REPORT_UID).delete({ documentId: report.documentId });
348
+ }
349
+
350
+ return { pruned: oldReports.length, remaining: total - oldReports.length };
351
+ },
352
+ };
353
+ };