strapi-content-sync-pro 1.0.1 → 1.0.3

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 (48) hide show
  1. package/README.md +84 -25
  2. package/admin/src/components/ConfigTab.jsx +29 -6
  3. package/admin/src/components/HelpTab.jsx +131 -32
  4. package/admin/src/components/MediaTab.jsx +7 -0
  5. package/admin/src/components/StatsTab.jsx +470 -0
  6. package/admin/src/components/SyncProfilesTab.jsx +63 -5
  7. package/admin/src/components/SyncTab.jsx +51 -7
  8. package/admin/src/pages/App/index.jsx +3 -0
  9. package/docs/Screenshot 2026-04-20 160506.png +0 -0
  10. package/docs/Screenshot 2026-04-20 160558.png +0 -0
  11. package/docs/Screenshot 2026-04-20 175903.png +0 -0
  12. package/docs/Screenshot 2026-04-20 175931.png +0 -0
  13. package/docs/Screenshot 2026-04-20 180001.png +0 -0
  14. package/docs/Screenshot 2026-04-20 180041.png +0 -0
  15. package/docs/Screenshot 2026-04-20 180116.png +0 -0
  16. package/docs/Screenshot 2026-04-20 180135.png +0 -0
  17. package/docs/Screenshot 2026-04-20 180202.png +0 -0
  18. package/docs/Screenshot 2026-04-20 180228.png +0 -0
  19. package/docs/Screenshot 2026-04-20 180251.png +0 -0
  20. package/docs/Screenshot 2026-04-20 180301.png +0 -0
  21. package/docs/clipchamp-screen-recording-script.md +0 -0
  22. package/docs/logo-horizontal.svg +33 -0
  23. package/docs/logo-mark.svg +38 -0
  24. package/docs/logo-square.svg +27 -0
  25. package/docs/production-readiness-status.md +34 -0
  26. package/docs/production-readiness-test-matrix.md +151 -0
  27. package/docs/test-environments-setup-legacy.txt +60 -0
  28. package/package.json +2 -1
  29. package/server/src/content-types/index.js +2 -0
  30. package/server/src/content-types/sync-run-report/schema.json +26 -0
  31. package/server/src/controllers/config.js +48 -5
  32. package/server/src/controllers/index.js +2 -0
  33. package/server/src/controllers/sync-log.js +6 -0
  34. package/server/src/controllers/sync-media.js +19 -0
  35. package/server/src/controllers/sync-stats.js +51 -0
  36. package/server/src/controllers/sync.js +9 -3
  37. package/server/src/routes/index.js +13 -0
  38. package/server/src/services/config.js +18 -2
  39. package/server/src/services/index.js +2 -0
  40. package/server/src/services/sync-execution.js +102 -5
  41. package/server/src/services/sync-log.js +36 -0
  42. package/server/src/services/sync-media.js +224 -1
  43. package/server/src/services/sync-profiles.js +92 -4
  44. package/server/src/services/sync-stats.js +353 -0
  45. package/server/src/services/sync.js +324 -97
  46. package/server/src/utils/applier.js +120 -13
  47. package/server/src/utils/comparator.js +22 -6
  48. package/server/src/utils/fetcher.js +11 -2
@@ -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
+ };