strapi-content-sync-pro 1.0.4 → 1.0.6

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 (59) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +32 -14
  3. package/admin/src/components/BulkTransferTab.jsx +185 -20
  4. package/admin/src/components/ConfigTab.jsx +81 -3
  5. package/admin/src/components/ContentTypesTab.jsx +28 -1
  6. package/admin/src/components/HelpTab.jsx +34 -0
  7. package/admin/src/components/LogsTab.jsx +66 -8
  8. package/admin/src/components/MediaTab.jsx +253 -36
  9. package/admin/src/components/SyncProfilesTab.jsx +140 -4
  10. package/admin/src/components/SyncTab.jsx +161 -35
  11. package/docs/Screenshot 2026-04-22 183540.png +0 -0
  12. package/docs/Screenshot 2026-04-22 183552.png +0 -0
  13. package/docs/Screenshot 2026-04-23 114332.png +0 -0
  14. package/docs/Screenshot 2026-04-23 114644.png +0 -0
  15. package/docs/Screenshot 2026-04-23 114651.png +0 -0
  16. package/docs/Screenshot 2026-04-23 114737.png +0 -0
  17. package/docs/Screenshot 2026-04-23 114904.png +0 -0
  18. package/docs/Screenshot 2026-04-23 114940.png +0 -0
  19. package/docs/Screenshot 2026-04-23 115003.png +0 -0
  20. package/docs/Screenshot 2026-04-23 115024.png +0 -0
  21. package/docs/Screenshot 2026-04-23 115116.png +0 -0
  22. package/docs/Screenshot 2026-04-23 115141.png +0 -0
  23. package/docs/Screenshot 2026-04-23 115252.png +0 -0
  24. package/docs/Screenshot 2026-04-23 115448.png +0 -0
  25. package/docs/Screenshot 2026-04-23 120534.png +0 -0
  26. package/docs/Screenshot 2026-04-23 122544.png +0 -0
  27. package/docs/Screenshot 2026-04-23 122712.png +0 -0
  28. package/docs/Screenshot 2026-04-23 122730.png +0 -0
  29. package/docs/Screenshot 2026-04-23 122858.png +0 -0
  30. package/docs/Screenshot 2026-04-23 122924.png +0 -0
  31. package/docs/Screenshot 2026-04-23 122937.png +0 -0
  32. package/docs/sync-strategy-approach-review.md +127 -0
  33. package/package.json +1 -1
  34. package/server/src/controllers/config.js +76 -3
  35. package/server/src/controllers/sync-media.js +24 -0
  36. package/server/src/routes/index.js +3 -0
  37. package/server/src/services/bulk-transfer.js +45 -1
  38. package/server/src/services/dependency-resolver.js +37 -0
  39. package/server/src/services/sync-execution.js +21 -9
  40. package/server/src/services/sync-media.js +168 -32
  41. package/server/src/services/sync-profiles.js +36 -15
  42. package/server/src/services/sync.js +234 -134
  43. package/server/src/utils/fetcher.js +7 -0
  44. package/docs/Screenshot 2026-04-20 160506.png +0 -0
  45. package/docs/Screenshot 2026-04-20 160558.png +0 -0
  46. package/docs/Screenshot 2026-04-20 175903.png +0 -0
  47. package/docs/Screenshot 2026-04-20 175931.png +0 -0
  48. package/docs/Screenshot 2026-04-20 180001.png +0 -0
  49. package/docs/Screenshot 2026-04-20 180041.png +0 -0
  50. package/docs/Screenshot 2026-04-20 180116.png +0 -0
  51. package/docs/Screenshot 2026-04-20 180135.png +0 -0
  52. package/docs/Screenshot 2026-04-20 180202.png +0 -0
  53. package/docs/Screenshot 2026-04-20 180228.png +0 -0
  54. package/docs/Screenshot 2026-04-20 180251.png +0 -0
  55. package/docs/Screenshot 2026-04-20 180301.png +0 -0
  56. package/docs/clipchamp-screen-recording-script.md +0 -0
  57. package/docs/production-readiness-status.md +0 -34
  58. package/docs/production-readiness-test-matrix.md +0 -151
  59. package/docs/test-environments-setup-legacy.txt +0 -60
@@ -147,6 +147,64 @@ module.exports = ({ strapi }) => {
147
147
  const log = strapi.log;
148
148
  const schedulerHandles = {};
149
149
 
150
+ // ── In-memory run controls (pause/resume/cancel) per profile ─────────────
151
+ // Key: profileId, Value: { signal: 'run' | 'pause' | 'cancel',
152
+ // resumeDeferred: { promise, resolve } | null,
153
+ // progress: { phase, processed, total, pushed, pulled, skipped, errors } }
154
+ const runControls = {};
155
+
156
+ function getControl(profileId) {
157
+ if (!runControls[profileId]) {
158
+ runControls[profileId] = {
159
+ signal: 'run',
160
+ resumeDeferred: null,
161
+ progress: { phase: 'idle', processed: 0, total: 0, pushed: 0, pulled: 0, skipped: 0, errors: 0 },
162
+ };
163
+ }
164
+ return runControls[profileId];
165
+ }
166
+
167
+ function makeDeferred() {
168
+ let resolve;
169
+ const promise = new Promise((r) => { resolve = r; });
170
+ return { promise, resolve };
171
+ }
172
+
173
+ // Cooperative checkpoint — call between units of work. Returns:
174
+ // { cancelled: true } if the run should abort, or null to continue.
175
+ async function checkpoint(profileId) {
176
+ const c = getControl(profileId);
177
+ if (c.signal === 'cancel') return { cancelled: true };
178
+ if (c.signal === 'pause') {
179
+ if (!c.resumeDeferred) c.resumeDeferred = makeDeferred();
180
+ // Mirror pause state into persisted status so UI sees it
181
+ try {
182
+ const s = await store().get({ key: STATUS_KEY }) || {};
183
+ s.paused = { ...(s.paused || {}), [profileId]: true };
184
+ await store().set({ key: STATUS_KEY, value: s });
185
+ } catch { /* ignore */ }
186
+ await c.resumeDeferred.promise;
187
+ c.resumeDeferred = null;
188
+ try {
189
+ const s = await store().get({ key: STATUS_KEY }) || {};
190
+ if (s.paused) { delete s.paused[profileId]; }
191
+ await store().set({ key: STATUS_KEY, value: s });
192
+ } catch { /* ignore */ }
193
+ if (c.signal === 'cancel') return { cancelled: true };
194
+ }
195
+ return null;
196
+ }
197
+
198
+ async function updateProgress(profileId, patch) {
199
+ const c = getControl(profileId);
200
+ c.progress = { ...c.progress, ...patch };
201
+ try {
202
+ const s = await store().get({ key: STATUS_KEY }) || {};
203
+ s.progress = { ...(s.progress || {}), [profileId]: { ...c.progress } };
204
+ await store().set({ key: STATUS_KEY, value: s });
205
+ } catch { /* ignore */ }
206
+ }
207
+
150
208
  function store() {
151
209
  return strapi.store({ type: 'plugin', name: PLUGIN_NAME });
152
210
  }
@@ -293,6 +351,9 @@ module.exports = ({ strapi }) => {
293
351
  lastExecutedAt: p.lastExecutedAt,
294
352
  nextExecutionAt: p.nextExecutionAt,
295
353
  running: !!(s && s.runningProfiles && s.runningProfiles[p.id]),
354
+ paused: !!(s && s.paused && s.paused[p.id]),
355
+ signal: runControls[p.id]?.signal || 'idle',
356
+ progress: (s && s.progress && s.progress[p.id]) || null,
296
357
  })),
297
358
  lastRunAt: s?.lastRunAt || null,
298
359
  lastResult: s?.lastResult || null,
@@ -755,13 +816,18 @@ module.exports = ({ strapi }) => {
755
816
 
756
817
  /**
757
818
  * Copy a page of files respecting settings.batchConcurrency.
819
+ * If checkAbort() returns { cancelled: true }, remaining items are skipped.
758
820
  */
759
- async function processBatch(items, worker, concurrency) {
760
- const out = { success: 0, skipped: 0, errors: [] };
821
+ async function processBatch(items, worker, concurrency, checkAbort) {
822
+ const out = { success: 0, skipped: 0, errors: [], cancelled: false };
761
823
  const c = Math.max(1, Math.min(concurrency || 1, 10));
762
824
  let i = 0;
763
825
  async function run() {
764
826
  while (i < items.length) {
827
+ if (checkAbort) {
828
+ const abort = await checkAbort();
829
+ if (abort && abort.cancelled) { out.cancelled = true; return; }
830
+ }
765
831
  const idx = i++;
766
832
  const item = items[idx];
767
833
  try {
@@ -785,15 +851,24 @@ module.exports = ({ strapi }) => {
785
851
 
786
852
  const totals = { pushed: 0, pulled: 0, skipped: 0, dbRowsUpdated: 0, morphLinksApplied: 0, morphLinksSkipped: 0, errors: [] };
787
853
  const started = Date.now();
854
+ const pid = profile.id;
855
+ const abort = () => checkpoint(pid);
856
+
857
+ await updateProgress(pid, { phase: 'indexing-local', processed: 0, total: 0, pushed: 0, pulled: 0, skipped: 0, errors: 0 });
788
858
 
789
859
  const localIndex = new Map();
790
860
  for await (const batch of iterateLocalFiles(settings.pageSize)) {
861
+ const a = await checkpoint(pid);
862
+ if (a && a.cancelled) { totals.cancelled = true; break; }
791
863
  for (const f of batch) localIndex.set(`${f.hash}|${f.name}`, f);
792
864
  }
793
865
 
794
866
  // PULL: remote -> local
795
- if (settings.direction === 'pull' || settings.direction === 'both') {
867
+ if (!totals.cancelled && (settings.direction === 'pull' || settings.direction === 'both')) {
868
+ await updateProgress(pid, { phase: 'pull' });
796
869
  for await (const remoteBatch of iterateRemoteFiles(remoteConfig, settings.pageSize)) {
870
+ const a = await checkpoint(pid);
871
+ if (a && a.cancelled) { totals.cancelled = true; break; }
797
872
  const filtered = remoteBatch.filter((f) => passesFilters(f, profile));
798
873
  const result = await processBatch(filtered, async (rf) => {
799
874
  const key = `${rf.hash}|${rf.name}`;
@@ -813,16 +888,24 @@ module.exports = ({ strapi }) => {
813
888
  await uploadBufferToLocal(rf, buf);
814
889
  }
815
890
  return 'success';
816
- }, settings.batchConcurrency);
891
+ }, settings.batchConcurrency, abort);
817
892
  totals.pulled += result.success;
818
893
  totals.skipped += result.skipped;
819
894
  totals.errors.push(...result.errors);
895
+ await updateProgress(pid, {
896
+ pulled: totals.pulled,
897
+ skipped: totals.skipped,
898
+ errors: totals.errors.length,
899
+ processed: totals.pulled + totals.pushed + totals.skipped,
900
+ });
901
+ if (result.cancelled) { totals.cancelled = true; break; }
820
902
  }
821
903
  }
822
904
 
823
- if (profile.syncDbRows) {
905
+ if (!totals.cancelled && profile.syncDbRows) {
824
906
  try {
825
907
  if (settings.direction === 'pull' || settings.direction === 'both') {
908
+ await updateProgress(pid, { phase: 'pull-morph' });
826
909
  const remoteLinks = await fetchRemoteMorphLinks(remoteConfig);
827
910
  const applyResult = await applyMorphLinks(remoteLinks);
828
911
  totals.morphLinksApplied += applyResult.applied || 0;
@@ -837,42 +920,58 @@ module.exports = ({ strapi }) => {
837
920
  }
838
921
 
839
922
  // PUSH: local -> remote
840
- if (settings.direction === 'push' || settings.direction === 'both') {
923
+ if (!totals.cancelled && (settings.direction === 'push' || settings.direction === 'both')) {
924
+ await updateProgress(pid, { phase: 'push-indexing-remote' });
841
925
  const remoteIndex = new Map();
842
926
  for await (const remoteBatch of iterateRemoteFiles(remoteConfig, settings.pageSize)) {
927
+ const a = await checkpoint(pid);
928
+ if (a && a.cancelled) { totals.cancelled = true; break; }
843
929
  for (const f of remoteBatch) remoteIndex.set(`${f.hash}|${f.name}`, f);
844
930
  }
845
931
 
846
- for await (const localBatch of iterateLocalFiles(settings.pageSize)) {
847
- const filtered = localBatch.filter((f) => passesFilters(f, profile));
848
- const result = await processBatch(filtered, async (lf) => {
849
- const key = `${lf.hash}|${lf.name}`;
850
- const rf = remoteIndex.get(key);
851
-
852
- // DB-row sync (push metadata)
853
- if (profile.syncDbRows && rf) {
854
- const dbResult = await syncDbRowPush(lf, rf, profile, remoteConfig);
855
- if (dbResult === 'updated') totals.dbRowsUpdated++;
856
- }
857
-
858
- // File-byte sync
859
- if (profile.syncFileBytes) {
860
- if (shouldSkip(lf, rf, settings)) return 'skipped';
861
- if (settings.dryRun) return 'success';
862
- const buf = await readLocalFileBuffer(lf);
863
- if (!buf) return 'skipped';
864
- await uploadBufferToRemote(remoteConfig, lf, buf);
865
- }
866
- return 'success';
867
- }, settings.batchConcurrency);
868
- totals.pushed += result.success;
869
- totals.skipped += result.skipped;
870
- totals.errors.push(...result.errors);
932
+ if (!totals.cancelled) {
933
+ await updateProgress(pid, { phase: 'push' });
934
+ for await (const localBatch of iterateLocalFiles(settings.pageSize)) {
935
+ const a = await checkpoint(pid);
936
+ if (a && a.cancelled) { totals.cancelled = true; break; }
937
+ const filtered = localBatch.filter((f) => passesFilters(f, profile));
938
+ const result = await processBatch(filtered, async (lf) => {
939
+ const key = `${lf.hash}|${lf.name}`;
940
+ const rf = remoteIndex.get(key);
941
+
942
+ // DB-row sync (push metadata)
943
+ if (profile.syncDbRows && rf) {
944
+ const dbResult = await syncDbRowPush(lf, rf, profile, remoteConfig);
945
+ if (dbResult === 'updated') totals.dbRowsUpdated++;
946
+ }
947
+
948
+ // File-byte sync
949
+ if (profile.syncFileBytes) {
950
+ if (shouldSkip(lf, rf, settings)) return 'skipped';
951
+ if (settings.dryRun) return 'success';
952
+ const buf = await readLocalFileBuffer(lf);
953
+ if (!buf) return 'skipped';
954
+ await uploadBufferToRemote(remoteConfig, lf, buf);
955
+ }
956
+ return 'success';
957
+ }, settings.batchConcurrency, abort);
958
+ totals.pushed += result.success;
959
+ totals.skipped += result.skipped;
960
+ totals.errors.push(...result.errors);
961
+ await updateProgress(pid, {
962
+ pushed: totals.pushed,
963
+ skipped: totals.skipped,
964
+ errors: totals.errors.length,
965
+ processed: totals.pulled + totals.pushed + totals.skipped,
966
+ });
967
+ if (result.cancelled) { totals.cancelled = true; break; }
968
+ }
871
969
  }
872
970
  }
873
971
 
874
- if (profile.syncDbRows && (settings.direction === 'push' || settings.direction === 'both')) {
972
+ if (!totals.cancelled && profile.syncDbRows && (settings.direction === 'push' || settings.direction === 'both')) {
875
973
  try {
974
+ await updateProgress(pid, { phase: 'push-morph' });
876
975
  const localLinks = await exportMorphLinks();
877
976
  const applyRemoteResult = await applyRemoteMorphLinks(remoteConfig, localLinks);
878
977
  totals.morphLinksApplied += applyRemoteResult.applied || 0;
@@ -885,6 +984,8 @@ module.exports = ({ strapi }) => {
885
984
  }
886
985
  }
887
986
 
987
+ await updateProgress(pid, { phase: totals.cancelled ? 'cancelled' : 'done' });
988
+
888
989
  const summary = {
889
990
  strategy: 'url',
890
991
  profileId: profile.id,
@@ -1041,9 +1142,17 @@ module.exports = ({ strapi }) => {
1041
1142
  const globalSettings = await getGlobalSettings();
1042
1143
  const merged = { ...globalSettings, ...profile, ...options };
1043
1144
 
1145
+ // Reset run controls for a fresh execution
1146
+ const control = getControl(profileId);
1147
+ control.signal = 'run';
1148
+ control.resumeDeferred = null;
1149
+ control.progress = { phase: 'starting', processed: 0, total: 0, pushed: 0, pulled: 0, skipped: 0, errors: 0 };
1150
+
1044
1151
  const statusData = await store().get({ key: STATUS_KEY }) || {};
1045
1152
  statusData.running = true;
1046
1153
  statusData.runningProfiles = { ...(statusData.runningProfiles || {}), [profileId]: true };
1154
+ statusData.progress = { ...(statusData.progress || {}), [profileId]: { ...control.progress } };
1155
+ if (statusData.paused) delete statusData.paused[profileId];
1047
1156
  await setStatus(statusData);
1048
1157
 
1049
1158
  try {
@@ -1059,6 +1168,7 @@ module.exports = ({ strapi }) => {
1059
1168
 
1060
1169
  const s2 = await store().get({ key: STATUS_KEY }) || {};
1061
1170
  delete (s2.runningProfiles || {})[profileId];
1171
+ if (s2.paused) delete s2.paused[profileId];
1062
1172
  s2.running = Object.keys(s2.runningProfiles || {}).length > 0;
1063
1173
  s2.lastRunAt = new Date().toISOString();
1064
1174
  s2.lastResult = result;
@@ -1068,6 +1178,7 @@ module.exports = ({ strapi }) => {
1068
1178
  } catch (err) {
1069
1179
  const s2 = await store().get({ key: STATUS_KEY }) || {};
1070
1180
  delete (s2.runningProfiles || {})[profileId];
1181
+ if (s2.paused) delete s2.paused[profileId];
1071
1182
  s2.running = Object.keys(s2.runningProfiles || {}).length > 0;
1072
1183
  s2.lastRunAt = new Date().toISOString();
1073
1184
  s2.lastResult = { error: err.message };
@@ -1076,6 +1187,28 @@ module.exports = ({ strapi }) => {
1076
1187
  }
1077
1188
  }
1078
1189
 
1190
+ async function pauseProfile(profileId) {
1191
+ const c = getControl(profileId);
1192
+ if (c.signal === 'cancel') return { ok: false, message: 'Run is cancelling' };
1193
+ c.signal = 'pause';
1194
+ return { ok: true, signal: c.signal };
1195
+ }
1196
+
1197
+ async function resumeProfile(profileId) {
1198
+ const c = getControl(profileId);
1199
+ if (c.signal !== 'pause') return { ok: false, message: `Run is not paused (signal=${c.signal})` };
1200
+ c.signal = 'run';
1201
+ if (c.resumeDeferred) { c.resumeDeferred.resolve(); }
1202
+ return { ok: true, signal: c.signal };
1203
+ }
1204
+
1205
+ async function cancelProfile(profileId) {
1206
+ const c = getControl(profileId);
1207
+ c.signal = 'cancel';
1208
+ if (c.resumeDeferred) { c.resumeDeferred.resolve(); } // unblock any pause wait
1209
+ return { ok: true, signal: c.signal };
1210
+ }
1211
+
1079
1212
  async function runActiveProfiles() {
1080
1213
  const profiles = await getProfiles();
1081
1214
  const active = profiles.filter((p) => p.active && p.strategy !== 'disabled');
@@ -1118,6 +1251,9 @@ module.exports = ({ strapi }) => {
1118
1251
  // Execution
1119
1252
  runProfile,
1120
1253
  runActiveProfiles,
1254
+ pauseProfile,
1255
+ resumeProfile,
1256
+ cancelProfile,
1121
1257
 
1122
1258
  // Morph link APIs (documentId-based)
1123
1259
  exportMorphLinks,
@@ -37,6 +37,7 @@ module.exports = ({ strapi }) => {
37
37
 
38
38
  const VALID_DIRECTIONS = ['push', 'pull', 'both', 'none'];
39
39
  const VALID_CONFLICT_STRATEGIES = ['latest', 'local_wins', 'remote_wins'];
40
+ const VALID_EXECUTION_STRATEGIES = ['hybrid_two_pass', 'one_pass'];
40
41
 
41
42
  async function getSyncMode() {
42
43
  const configService = strapi.plugin('strapi-content-sync-pro').service('config');
@@ -70,24 +71,33 @@ module.exports = ({ strapi }) => {
70
71
  const data = await store.get({ key: STORE_KEY });
71
72
  const profiles = data || [];
72
73
  const syncMode = await getSyncMode();
73
- if (syncMode !== 'single_side') return profiles;
74
74
 
75
75
  let changed = false;
76
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
- };
77
+ let next = p;
78
+
79
+ if (!next.executionStrategy) {
80
+ changed = true;
81
+ next = { ...next, executionStrategy: 'hybrid_two_pass' };
82
+ }
83
+
84
+ if (syncMode === 'single_side' && next.direction !== 'pull') {
85
+ changed = true;
86
+ next = {
87
+ ...next,
88
+ direction: 'pull',
89
+ syncDeletions: !!next.syncDeletions,
90
+ fieldPolicies: Array.isArray(next.fieldPolicies)
91
+ ? next.fieldPolicies.map((fp) => ({
92
+ ...fp,
93
+ direction: fp.direction === 'none' ? 'none' : 'pull',
94
+ }))
95
+ : next.fieldPolicies,
96
+ updatedAt: new Date().toISOString(),
97
+ };
98
+ }
99
+
100
+ return next;
91
101
  });
92
102
 
93
103
  if (changed) {
@@ -222,12 +232,17 @@ module.exports = ({ strapi }) => {
222
232
  }
223
233
  }
224
234
 
235
+ if (profileData.executionStrategy && !VALID_EXECUTION_STRATEGIES.includes(profileData.executionStrategy)) {
236
+ throw new Error(`Invalid execution strategy "${profileData.executionStrategy}"`);
237
+ }
238
+
225
239
  const newProfile = {
226
240
  id: generateId(),
227
241
  name: profileData.name,
228
242
  contentType: profileData.contentType,
229
243
  direction: profileData.direction || 'both',
230
244
  conflictStrategy: profileData.conflictStrategy || 'latest',
245
+ executionStrategy: profileData.executionStrategy || 'hybrid_two_pass',
231
246
  syncDeletions: !!profileData.syncDeletions,
232
247
  isActive: profileData.isActive || false,
233
248
  isSimple: profileData.isSimple !== false, // Default to simple mode
@@ -299,6 +314,10 @@ module.exports = ({ strapi }) => {
299
314
  }
300
315
  }
301
316
 
317
+ if (updates.executionStrategy && !VALID_EXECUTION_STRATEGIES.includes(updates.executionStrategy)) {
318
+ throw new Error(`Invalid execution strategy "${updates.executionStrategy}"`);
319
+ }
320
+
302
321
  // If setting this profile as active, deactivate others for same content type
303
322
  if (updates.isActive) {
304
323
  const contentType = updates.contentType || profiles[index].contentType;
@@ -392,6 +411,7 @@ module.exports = ({ strapi }) => {
392
411
  return this.createProfile({
393
412
  ...presetConfig,
394
413
  contentType: contentTypeUid,
414
+ executionStrategy: 'hybrid_two_pass',
395
415
  syncDeletions: false,
396
416
  isSimple: true,
397
417
  isActive: false,
@@ -428,6 +448,7 @@ module.exports = ({ strapi }) => {
428
448
  return {
429
449
  direction: activeProfile.direction,
430
450
  conflictStrategy: activeProfile.conflictStrategy,
451
+ executionStrategy: activeProfile.executionStrategy || 'hybrid_two_pass',
431
452
  fieldPolicies: activeProfile.isSimple ? null : await this.getFieldPoliciesForContentType(contentTypeUid),
432
453
  };
433
454
  },