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
@@ -155,6 +155,31 @@ module.exports = ({ strapi }) => {
155
155
  return strapi.plugin(PLUGIN_NAME);
156
156
  }
157
157
 
158
+ // ---------------------------------------------------------------------------
159
+ // Morph join-table resolution
160
+ // ---------------------------------------------------------------------------
161
+ // Strapi's upload plugin stores polymorphic file↔entity links in a morph
162
+ // join table whose name differs between Strapi versions (e.g.
163
+ // `files_related_morphs` on some v4 builds vs `files_related_mph` on
164
+ // Strapi v5). Resolve it from ORM metadata so the plugin works everywhere.
165
+ let _morphTableCache = null;
166
+ function resolveMorphTable() {
167
+ if (_morphTableCache) return _morphTableCache;
168
+ const candidates = [];
169
+ try {
170
+ const meta = strapi.db?.metadata?.get?.('plugin::upload.file');
171
+ const attr = meta?.attributes?.related;
172
+ if (attr?.joinTable?.name) candidates.push(attr.joinTable.name);
173
+ if (attr?.pivotTable) candidates.push(attr.pivotTable);
174
+ } catch {
175
+ // ignore — fall back to known names below
176
+ }
177
+ // Known historical defaults, most-recent first.
178
+ candidates.push('files_related_mph', 'files_related_morphs');
179
+ _morphTableCache = candidates.find((n) => !!n) || 'files_related_mph';
180
+ return _morphTableCache;
181
+ }
182
+
158
183
  // ---------------------------------------------------------------------------
159
184
  // Profile CRUD
160
185
  // ---------------------------------------------------------------------------
@@ -408,6 +433,170 @@ module.exports = ({ strapi }) => {
408
433
  return 'needs_bytes';
409
434
  }
410
435
 
436
+ async function exportMorphLinks() {
437
+ const morphTable = resolveMorphTable();
438
+ const rows = await strapi.db.connection(morphTable).select('*');
439
+ const fileCache = new Map();
440
+ const relatedDocCache = new Map();
441
+ const out = [];
442
+
443
+ for (const row of rows) {
444
+ const fileId = Number(row.file_id);
445
+ if (!fileCache.has(fileId)) {
446
+ const file = await strapi.db.query('plugin::upload.file').findOne({
447
+ where: { id: fileId },
448
+ select: ['id', 'documentId'],
449
+ });
450
+ fileCache.set(fileId, file || null);
451
+ }
452
+
453
+ const file = fileCache.get(fileId);
454
+ if (!file?.documentId) continue;
455
+
456
+ const relatedType = row.related_type;
457
+ const relatedId = Number(row.related_id);
458
+ const relatedKey = `${relatedType}:${relatedId}`;
459
+
460
+ if (!relatedDocCache.has(relatedKey)) {
461
+ try {
462
+ const entity = await strapi.db.query(relatedType).findOne({
463
+ where: { id: relatedId },
464
+ select: ['id', 'documentId'],
465
+ });
466
+ relatedDocCache.set(relatedKey, entity?.documentId || null);
467
+ } catch {
468
+ relatedDocCache.set(relatedKey, null);
469
+ }
470
+ }
471
+
472
+ const relatedDocumentId = relatedDocCache.get(relatedKey);
473
+ if (!relatedDocumentId) continue;
474
+
475
+ out.push({
476
+ fileDocumentId: file.documentId,
477
+ relatedType,
478
+ relatedDocumentId,
479
+ field: row.field || null,
480
+ order: row.order || 1,
481
+ });
482
+ }
483
+
484
+ return out;
485
+ }
486
+
487
+ async function applyMorphLinks(links = []) {
488
+ const applied = [];
489
+ const skipped = [];
490
+ const errors = [];
491
+
492
+ for (const link of links) {
493
+ try {
494
+ if (!link?.fileDocumentId || !link?.relatedType || !link?.relatedDocumentId) {
495
+ skipped.push({ link, reason: 'missing required documentId fields' });
496
+ continue;
497
+ }
498
+
499
+ const file = await strapi.db.query('plugin::upload.file').findOne({
500
+ where: { documentId: link.fileDocumentId },
501
+ select: ['id', 'documentId'],
502
+ });
503
+ if (!file?.id) {
504
+ skipped.push({ link, reason: 'file documentId not found locally' });
505
+ continue;
506
+ }
507
+
508
+ let related = null;
509
+ try {
510
+ related = await strapi.db.query(link.relatedType).findOne({
511
+ where: { documentId: link.relatedDocumentId },
512
+ select: ['id', 'documentId'],
513
+ });
514
+ } catch {
515
+ skipped.push({ link, reason: 'related type not queryable locally' });
516
+ continue;
517
+ }
518
+
519
+ if (!related?.id) {
520
+ skipped.push({ link, reason: 'related documentId not found locally' });
521
+ continue;
522
+ }
523
+
524
+ const morphTable = resolveMorphTable();
525
+ let existsQ = strapi.db.connection(morphTable)
526
+ .where('file_id', file.id)
527
+ .andWhere('related_id', related.id)
528
+ .andWhere('related_type', link.relatedType);
529
+
530
+ if (link.field) existsQ = existsQ.andWhere('field', link.field);
531
+ else existsQ = existsQ.whereNull('field');
532
+
533
+ const existing = await existsQ.first();
534
+ if (existing) {
535
+ skipped.push({ link, reason: 'morph link already exists' });
536
+ continue;
537
+ }
538
+
539
+ await strapi.db.connection(morphTable).insert({
540
+ file_id: file.id,
541
+ related_id: related.id,
542
+ related_type: link.relatedType,
543
+ field: link.field || null,
544
+ order: link.order || 1,
545
+ });
546
+
547
+ applied.push(link);
548
+ } catch (err) {
549
+ errors.push({ link, error: err.message });
550
+ }
551
+ }
552
+
553
+ return {
554
+ total: links.length,
555
+ applied: applied.length,
556
+ skipped: skipped.length,
557
+ errors,
558
+ };
559
+ }
560
+
561
+ async function fetchRemoteMorphLinks(remoteConfig) {
562
+ const url = new URL('/api/strapi-content-sync-pro/media-sync/morph-links', remoteConfig.baseUrl);
563
+ const res = await fetch(url.toString(), {
564
+ method: 'GET',
565
+ headers: {
566
+ Authorization: `Bearer ${remoteConfig.apiToken}`,
567
+ 'Content-Type': 'application/json',
568
+ },
569
+ });
570
+
571
+ if (!res.ok) {
572
+ const body = await safeReadBody(res);
573
+ throw new Error(`Remote morph-links fetch failed (${res.status}): ${body}`);
574
+ }
575
+
576
+ const json = await res.json();
577
+ return json?.data || [];
578
+ }
579
+
580
+ async function applyRemoteMorphLinks(remoteConfig, links = []) {
581
+ const url = new URL('/api/strapi-content-sync-pro/media-sync/morph-links/apply', remoteConfig.baseUrl);
582
+ const res = await fetch(url.toString(), {
583
+ method: 'POST',
584
+ headers: {
585
+ Authorization: `Bearer ${remoteConfig.apiToken}`,
586
+ 'Content-Type': 'application/json',
587
+ },
588
+ body: JSON.stringify({ links }),
589
+ });
590
+
591
+ if (!res.ok) {
592
+ const body = await safeReadBody(res);
593
+ throw new Error(`Remote morph-links apply failed (${res.status}): ${body}`);
594
+ }
595
+
596
+ const json = await res.json();
597
+ return json?.data || { total: links.length, applied: 0, skipped: 0, errors: [] };
598
+ }
599
+
411
600
  // ---------------------------------------------------------------------------
412
601
  // URL strategy
413
602
  // ---------------------------------------------------------------------------
@@ -594,7 +783,7 @@ module.exports = ({ strapi }) => {
594
783
  const remoteConfig = await configService.getConfig({ safe: false });
595
784
  if (!remoteConfig?.baseUrl) throw new Error('Remote server not configured');
596
785
 
597
- const totals = { pushed: 0, pulled: 0, skipped: 0, dbRowsUpdated: 0, errors: [] };
786
+ const totals = { pushed: 0, pulled: 0, skipped: 0, dbRowsUpdated: 0, morphLinksApplied: 0, morphLinksSkipped: 0, errors: [] };
598
787
  const started = Date.now();
599
788
 
600
789
  const localIndex = new Map();
@@ -631,6 +820,22 @@ module.exports = ({ strapi }) => {
631
820
  }
632
821
  }
633
822
 
823
+ if (profile.syncDbRows) {
824
+ try {
825
+ if (settings.direction === 'pull' || settings.direction === 'both') {
826
+ const remoteLinks = await fetchRemoteMorphLinks(remoteConfig);
827
+ const applyResult = await applyMorphLinks(remoteLinks);
828
+ totals.morphLinksApplied += applyResult.applied || 0;
829
+ totals.morphLinksSkipped += applyResult.skipped || 0;
830
+ if (Array.isArray(applyResult.errors) && applyResult.errors.length > 0) {
831
+ totals.errors.push(...applyResult.errors.map((e) => ({ name: 'morph_pull', error: e.error || 'morph apply error' })));
832
+ }
833
+ }
834
+ } catch (err) {
835
+ totals.errors.push({ name: 'morph_pull', error: err.message });
836
+ }
837
+ }
838
+
634
839
  // PUSH: local -> remote
635
840
  if (settings.direction === 'push' || settings.direction === 'both') {
636
841
  const remoteIndex = new Map();
@@ -666,6 +871,20 @@ module.exports = ({ strapi }) => {
666
871
  }
667
872
  }
668
873
 
874
+ if (profile.syncDbRows && (settings.direction === 'push' || settings.direction === 'both')) {
875
+ try {
876
+ const localLinks = await exportMorphLinks();
877
+ const applyRemoteResult = await applyRemoteMorphLinks(remoteConfig, localLinks);
878
+ totals.morphLinksApplied += applyRemoteResult.applied || 0;
879
+ totals.morphLinksSkipped += applyRemoteResult.skipped || 0;
880
+ if (Array.isArray(applyRemoteResult.errors) && applyRemoteResult.errors.length > 0) {
881
+ totals.errors.push(...applyRemoteResult.errors.map((e) => ({ name: 'morph_push', error: e.error || 'remote morph apply error' })));
882
+ }
883
+ } catch (err) {
884
+ totals.errors.push({ name: 'morph_push', error: err.message });
885
+ }
886
+ }
887
+
669
888
  const summary = {
670
889
  strategy: 'url',
671
890
  profileId: profile.id,
@@ -900,6 +1119,10 @@ module.exports = ({ strapi }) => {
900
1119
  runProfile,
901
1120
  runActiveProfiles,
902
1121
 
1122
+ // Morph link APIs (documentId-based)
1123
+ exportMorphLinks,
1124
+ applyMorphLinks,
1125
+
903
1126
  // Schedulers
904
1127
  initializeSchedulers,
905
1128
  stopAllSchedulers,
@@ -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: [],