strapi-content-sync-pro 1.0.2 → 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 (33) hide show
  1. package/README.md +65 -18
  2. package/admin/src/components/ConfigTab.jsx +25 -4
  3. package/admin/src/components/HelpTab.jsx +88 -11
  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/clipchamp-screen-recording-script.md +0 -0
  10. package/docs/production-readiness-status.md +34 -0
  11. package/docs/production-readiness-test-matrix.md +151 -0
  12. package/docs/test-environments-setup-legacy.txt +60 -0
  13. package/package.json +1 -1
  14. package/server/src/content-types/index.js +2 -0
  15. package/server/src/content-types/sync-run-report/schema.json +26 -0
  16. package/server/src/controllers/config.js +48 -5
  17. package/server/src/controllers/index.js +2 -0
  18. package/server/src/controllers/sync-log.js +6 -0
  19. package/server/src/controllers/sync-media.js +19 -0
  20. package/server/src/controllers/sync-stats.js +51 -0
  21. package/server/src/controllers/sync.js +9 -3
  22. package/server/src/routes/index.js +13 -0
  23. package/server/src/services/config.js +18 -2
  24. package/server/src/services/index.js +2 -0
  25. package/server/src/services/sync-execution.js +102 -5
  26. package/server/src/services/sync-log.js +36 -0
  27. package/server/src/services/sync-media.js +224 -1
  28. package/server/src/services/sync-profiles.js +92 -4
  29. package/server/src/services/sync-stats.js +353 -0
  30. package/server/src/services/sync.js +186 -100
  31. package/server/src/utils/applier.js +120 -13
  32. package/server/src/utils/comparator.js +22 -6
  33. package/server/src/utils/fetcher.js +11 -2
@@ -13,6 +13,10 @@ const contentApiRoutes = [
13
13
  { method: 'GET', path: '/enforcement/local-info', handler: 'syncEnforcement.getLocalInfo', config: { policies: [] } },
14
14
  { method: 'GET', path: '/enforcement/schema/:uid', handler: 'syncEnforcement.getLocalSchema', config: { policies: [] } },
15
15
  { method: 'POST', path: '/receive', handler: 'sync.receive', config: { policies: [], auth: false, middlewares: [verifySignature] } },
16
+
17
+ // Media morph-link sync (called by the peer instance during runProfile)
18
+ { method: 'GET', path: '/media-sync/morph-links', handler: 'syncMedia.getMorphLinks', config: { policies: [] } },
19
+ { method: 'POST', path: '/media-sync/morph-links/apply', handler: 'syncMedia.applyMorphLinks', config: { policies: [] } },
16
20
  ];
17
21
 
18
22
  const adminRoutes = [
@@ -35,6 +39,13 @@ const adminRoutes = [
35
39
  // Logs
36
40
  { method: 'GET', path: '/logs', handler: 'syncLog.find', config: { policies: [] } },
37
41
 
42
+ // Stats and retention
43
+ { method: 'GET', path: '/stats/snapshot', handler: 'syncStats.getSnapshot', config: { policies: [] } },
44
+ { method: 'GET', path: '/stats/reports', handler: 'syncStats.getReports', config: { policies: [] } },
45
+ { method: 'POST', path: '/stats/reports/clear', handler: 'syncStats.clearReports', config: { policies: [] } },
46
+ { method: 'POST', path: '/stats/retention/run', handler: 'syncStats.runRetention', config: { policies: [] } },
47
+ { method: 'POST', path: '/logs/clear', handler: 'syncLog.clear', config: { policies: [] } },
48
+
38
49
  // Sync Profiles
39
50
  { method: 'GET', path: '/sync-profiles', handler: 'syncProfiles.find', config: { policies: [] } },
40
51
  { method: 'GET', path: '/sync-profiles/:id', handler: 'syncProfiles.findOne', config: { policies: [] } },
@@ -81,6 +92,8 @@ const adminRoutes = [
81
92
  { method: 'POST', path: '/media-sync/profiles/:id/activate', handler: 'syncMedia.activateProfile', config: { policies: [] } },
82
93
  { method: 'POST', path: '/media-sync/profiles/:id/run', handler: 'syncMedia.runProfile', config: { policies: [] } },
83
94
  { method: 'POST', path: '/media-sync/run-active', handler: 'syncMedia.runActiveProfiles', config: { policies: [] } },
95
+ { method: 'GET', path: '/media-sync/morph-links', handler: 'syncMedia.getMorphLinks', config: { policies: [] } },
96
+ { method: 'POST', path: '/media-sync/morph-links/apply', handler: 'syncMedia.applyMorphLinks', config: { policies: [] } },
84
97
  { method: 'GET', path: '/media-sync/global-settings', handler: 'syncMedia.getGlobalSettings', config: { policies: [] } },
85
98
  { method: 'PUT', path: '/media-sync/global-settings', handler: 'syncMedia.updateGlobalSettings', config: { policies: [] } },
86
99
  { method: 'GET', path: '/media-sync/defaults', handler: 'syncMedia.getDefaults', config: { policies: [] } },
@@ -3,6 +3,7 @@
3
3
  const STORE_KEY = 'remote-server-config';
4
4
 
5
5
  const SENSITIVE_FIELDS = ['apiToken', 'sharedSecret'];
6
+ const VALID_SYNC_MODES = ['paired', 'single_side'];
6
7
 
7
8
  module.exports = ({ strapi }) => {
8
9
  function getStore() {
@@ -21,11 +22,16 @@ module.exports = ({ strapi }) => {
21
22
  return null;
22
23
  }
23
24
 
25
+ const normalized = {
26
+ syncMode: 'paired',
27
+ ...data,
28
+ };
29
+
24
30
  if (!safe) {
25
- return data;
31
+ return normalized;
26
32
  }
27
33
 
28
- const sanitized = { ...data };
34
+ const sanitized = { ...normalized };
29
35
  for (const field of SENSITIVE_FIELDS) {
30
36
  if (sanitized[field]) {
31
37
  sanitized[field] = '••••••••';
@@ -59,6 +65,16 @@ module.exports = ({ strapi }) => {
59
65
  if (config.sharedSecret !== undefined) {
60
66
  merged.sharedSecret = config.sharedSecret;
61
67
  }
68
+ if (config.syncMode !== undefined) {
69
+ if (!VALID_SYNC_MODES.includes(config.syncMode)) {
70
+ throw new Error(`syncMode must be one of: ${VALID_SYNC_MODES.join(', ')}`);
71
+ }
72
+ merged.syncMode = config.syncMode;
73
+ }
74
+
75
+ if (!merged.syncMode) {
76
+ merged.syncMode = 'paired';
77
+ }
62
78
 
63
79
  await store.set({ key: STORE_KEY, value: merged });
64
80
 
@@ -12,6 +12,7 @@ const dependencyResolver = require('./dependency-resolver');
12
12
  const syncEnforcement = require('./sync-enforcement');
13
13
  const syncMedia = require('./sync-media');
14
14
  const alerts = require('./alerts');
15
+ const syncStats = require('./sync-stats');
15
16
 
16
17
  module.exports = {
17
18
  ping,
@@ -26,5 +27,6 @@ module.exports = {
26
27
  syncEnforcement,
27
28
  syncMedia,
28
29
  alerts,
30
+ syncStats,
29
31
  };
30
32
 
@@ -67,6 +67,8 @@ module.exports = ({ strapi }) => {
67
67
  retryOnFailure: true,
68
68
  retryAttempts: 3,
69
69
  retryDelayMs: 5000,
70
+ maxLogEntries: 2000,
71
+ maxReportEntries: 200,
70
72
  // Pagination for content-type sync fetches (local + remote). Larger pages
71
73
  // are faster but use more memory per chunk. 100 is a safe default.
72
74
  syncPageSize: 100,
@@ -75,6 +77,12 @@ module.exports = ({ strapi }) => {
75
77
  const VALID_EXECUTION_MODES = ['on_demand', 'scheduled', 'live'];
76
78
  const VALID_SCHEDULE_TYPES = ['interval', 'timeout', 'cron', 'external'];
77
79
 
80
+ async function getSyncMode() {
81
+ const configService = plugin().service('config');
82
+ const config = await configService.getConfig({ safe: false });
83
+ return config?.syncMode || 'paired';
84
+ }
85
+
78
86
  // Basic cron validator: 5 or 6 space-separated fields
79
87
  function isValidCronExpression(expr) {
80
88
  if (typeof expr !== 'string') return false;
@@ -156,6 +164,11 @@ module.exports = ({ strapi }) => {
156
164
  throw new Error(`Invalid schedule type "${executionSettings.scheduleType}". Must be one of: ${VALID_SCHEDULE_TYPES.join(', ')}`);
157
165
  }
158
166
 
167
+ const syncMode = await getSyncMode();
168
+ if (syncMode === 'single_side' && executionSettings.executionMode === 'live') {
169
+ throw new Error('Live execution mode is not available in single-side mode. Use on_demand or scheduled.');
170
+ }
171
+
159
172
  // Validate schedule interval (used by interval & timeout types)
160
173
  if (executionSettings.scheduleInterval !== undefined) {
161
174
  if (executionSettings.scheduleInterval < 1 || executionSettings.scheduleInterval > 1440) {
@@ -185,6 +198,11 @@ module.exports = ({ strapi }) => {
185
198
  ...executionSettings,
186
199
  updatedAt: new Date().toISOString(),
187
200
  };
201
+
202
+ if (syncMode === 'single_side' && merged.executionMode === 'live') {
203
+ merged.executionMode = 'on_demand';
204
+ merged.enabled = false;
205
+ }
188
206
  settings.profiles[profileId] = merged;
189
207
 
190
208
  // Calculate an advisory nextExecutionAt for scheduled mode
@@ -223,11 +241,20 @@ module.exports = ({ strapi }) => {
223
241
  const store = getStore();
224
242
  const settings = await this.getExecutionSettings();
225
243
 
226
- settings.globalSettings = {
244
+ const next = {
227
245
  ...settings.globalSettings,
228
246
  ...globalSettings,
229
247
  };
230
248
 
249
+ if (next.maxLogEntries !== undefined && Number(next.maxLogEntries) < 100) {
250
+ throw new Error('maxLogEntries must be at least 100');
251
+ }
252
+ if (next.maxReportEntries !== undefined && Number(next.maxReportEntries) < 10) {
253
+ throw new Error('maxReportEntries must be at least 10');
254
+ }
255
+
256
+ settings.globalSettings = next;
257
+
231
258
  await store.set({ key: STORE_KEY, value: settings });
232
259
  return settings.globalSettings;
233
260
  },
@@ -238,8 +265,10 @@ module.exports = ({ strapi }) => {
238
265
  async executeProfile(profileId, options = {}) {
239
266
  const syncService = plugin().service('sync');
240
267
  const profilesService = plugin().service('syncProfiles');
268
+ const dependencyResolver = plugin().service('dependencyResolver');
241
269
  const alertsService = plugin().service('alerts');
242
270
  const logService = plugin().service('syncLog');
271
+ const syncStatsService = plugin().service('syncStats');
243
272
 
244
273
  const profile = await profilesService.getProfile(profileId);
245
274
  if (!profile) {
@@ -251,6 +280,11 @@ module.exports = ({ strapi }) => {
251
280
  const dependencyDepth = options.dependencyDepth ?? executionSettings.dependencyDepth ?? 1;
252
281
 
253
282
  const startTime = new Date();
283
+ const reportHandle = await syncStatsService.createRunReport({
284
+ runType: 'profile_execution',
285
+ trigger: executionSettings.executionMode || 'on_demand',
286
+ contentTypes: [profile.contentType],
287
+ });
254
288
 
255
289
  try {
256
290
  // Log execution start
@@ -263,6 +297,41 @@ module.exports = ({ strapi }) => {
263
297
  details: { profileId, syncDependencies, dependencyDepth },
264
298
  });
265
299
 
300
+ const dependencyResults = [];
301
+ if (syncDependencies) {
302
+ const dependencyOrder = dependencyResolver
303
+ .getSyncOrder(profile.contentType, dependencyDepth)
304
+ .filter((uid) => uid !== profile.contentType && uid.startsWith('api::') && !!strapi.contentTypes[uid]);
305
+
306
+ for (const dependencyUid of dependencyOrder) {
307
+ const syncConfigService = plugin().service('syncConfig');
308
+ const syncConfig = await syncConfigService.getSyncConfig();
309
+ const depEnabled = (syncConfig.contentTypes || []).some((ct) => ct.uid === dependencyUid && ct.enabled);
310
+ if (!depEnabled) {
311
+ dependencyResults.push({
312
+ uid: dependencyUid,
313
+ profile: null,
314
+ skipped: true,
315
+ reason: 'dependency content-type not enabled for sync',
316
+ });
317
+ continue;
318
+ }
319
+
320
+ const dependencyProfile = await profilesService.getActiveProfileForContentType(dependencyUid);
321
+ const dependencyResult = await syncService.syncContentType(dependencyUid, {
322
+ profile: dependencyProfile,
323
+ syncDependencies: false,
324
+ dependencyDepth: 1,
325
+ });
326
+
327
+ dependencyResults.push({
328
+ uid: dependencyUid,
329
+ profile: dependencyProfile ? { id: dependencyProfile.id, name: dependencyProfile.name } : null,
330
+ result: dependencyResult,
331
+ });
332
+ }
333
+ }
334
+
266
335
  // Execute sync
267
336
  const result = await syncService.syncContentType(profile.contentType, {
268
337
  profile,
@@ -270,19 +339,39 @@ module.exports = ({ strapi }) => {
270
339
  dependencyDepth,
271
340
  });
272
341
 
342
+ const fullResult = {
343
+ ...result,
344
+ dependencyResults,
345
+ };
346
+
273
347
  // Update last execution time
274
348
  await this.updateLastExecution(profileId);
275
349
 
350
+ await syncStatsService.completeRunReport(reportHandle.reportId, {
351
+ status: 'success',
352
+ summary: fullResult,
353
+ });
354
+
355
+ const globalSettings = await this.getGlobalSettings();
356
+ await logService.applyRetention({ maxLogs: globalSettings.maxLogEntries });
357
+ await syncStatsService.applyRetention({ maxReports: globalSettings.maxReportEntries });
358
+
276
359
  // Send success alert
277
360
  await alertsService.sendAlert('sync_success', {
278
361
  profile: profile.name,
279
362
  contentType: profile.contentType,
280
- result,
363
+ result: fullResult,
281
364
  duration: Date.now() - startTime.getTime(),
282
365
  });
283
366
 
284
- return result;
367
+ return fullResult;
285
368
  } catch (error) {
369
+ await syncStatsService.completeRunReport(reportHandle.reportId, {
370
+ status: 'error',
371
+ summary: null,
372
+ error: error.message,
373
+ });
374
+
286
375
  // Send failure alert
287
376
  await alertsService.sendAlert('sync_failure', {
288
377
  profile: profile.name,
@@ -515,23 +604,31 @@ module.exports = ({ strapi }) => {
515
604
  const profilesService = plugin().service('syncProfiles');
516
605
  const profiles = await profilesService.getProfiles();
517
606
 
607
+ const syncMode = await getSyncMode();
518
608
  const status = [];
519
609
  for (const profile of profiles) {
520
610
  const execSettings = settings.profiles[profile.id] || {};
521
611
  const handle = schedulerHandles[profile.id];
612
+ const executionMode = syncMode === 'single_side' && execSettings.executionMode === 'live'
613
+ ? 'on_demand'
614
+ : (execSettings.executionMode || 'on_demand');
615
+
522
616
  status.push({
523
617
  profileId: profile.id,
524
618
  profileName: profile.name,
525
619
  contentType: profile.contentType,
526
- executionMode: execSettings.executionMode || 'on_demand',
620
+ executionMode,
527
621
  scheduleType: execSettings.scheduleType || null,
528
622
  scheduleInterval: execSettings.scheduleInterval || null,
529
623
  cronExpression: execSettings.cronExpression || null,
530
- enabled: execSettings.enabled || false,
624
+ enabled: syncMode === 'single_side' && execSettings.executionMode === 'live'
625
+ ? false
626
+ : (execSettings.enabled || false),
531
627
  lastExecutedAt: execSettings.lastExecutedAt || null,
532
628
  nextExecutionAt: execSettings.nextExecutionAt || null,
533
629
  isSchedulerRunning: !!handle && !handle.external,
534
630
  isExternal: !!(handle && handle.external),
631
+ syncMode,
535
632
  });
536
633
  }
537
634
 
@@ -53,4 +53,40 @@ module.exports = ({ strapi }) => ({
53
53
  },
54
54
  };
55
55
  },
56
+
57
+ async clearLogs() {
58
+ const existing = await strapi.documents(CONTENT_TYPE_UID).findMany({
59
+ fields: ['documentId'],
60
+ limit: 10000,
61
+ sort: { createdAt: 'desc' },
62
+ });
63
+
64
+ for (const entry of existing) {
65
+ if (!entry?.documentId) continue;
66
+ await strapi.documents(CONTENT_TYPE_UID).delete({ documentId: entry.documentId });
67
+ }
68
+
69
+ return { deleted: existing.length };
70
+ },
71
+
72
+ async applyRetention({ maxLogs = 2000 } = {}) {
73
+ const safeMax = Math.max(100, Number(maxLogs) || 2000);
74
+ const count = await strapi.documents(CONTENT_TYPE_UID).count();
75
+ if (count <= safeMax) return { pruned: 0, remaining: count };
76
+
77
+ const excess = count - safeMax;
78
+ const toDelete = await strapi.documents(CONTENT_TYPE_UID).findMany({
79
+ fields: ['documentId'],
80
+ sort: { createdAt: 'asc' },
81
+ limit: excess,
82
+ start: 0,
83
+ });
84
+
85
+ for (const entry of toDelete) {
86
+ if (!entry?.documentId) continue;
87
+ await strapi.documents(CONTENT_TYPE_UID).delete({ documentId: entry.documentId });
88
+ }
89
+
90
+ return { pruned: toDelete.length, remaining: count - toDelete.length };
91
+ },
56
92
  });
@@ -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,