strapi-content-sync-pro 1.0.5 → 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.
@@ -140,6 +140,49 @@ module.exports = ({ strapi }) => {
140
140
  return out;
141
141
  }
142
142
 
143
+ function orderByDependencies(uids) {
144
+ const depResolver = plugin().service('dependencyResolver');
145
+ const uidSet = new Set(uids);
146
+ const inDegree = new Map();
147
+ const adjacency = new Map();
148
+
149
+ uids.forEach((uid) => {
150
+ inDegree.set(uid, 0);
151
+ adjacency.set(uid, []);
152
+ });
153
+
154
+ for (const uid of uids) {
155
+ try {
156
+ const rels = depResolver.analyzeContentType(uid)?.relations || [];
157
+ for (const rel of rels) {
158
+ const depUid = rel.target;
159
+ if (!uidSet.has(depUid) || depUid === uid) continue;
160
+ adjacency.get(depUid).push(uid);
161
+ inDegree.set(uid, (inDegree.get(uid) || 0) + 1);
162
+ }
163
+ } catch (_) {
164
+ // Ignore bad schema and keep fallback order.
165
+ }
166
+ }
167
+
168
+ const queue = uids.filter((uid) => (inDegree.get(uid) || 0) === 0);
169
+ const ordered = [];
170
+ while (queue.length > 0) {
171
+ const uid = queue.shift();
172
+ ordered.push(uid);
173
+ for (const next of adjacency.get(uid) || []) {
174
+ const deg = (inDegree.get(next) || 0) - 1;
175
+ inDegree.set(next, deg);
176
+ if (deg === 0) queue.push(next);
177
+ }
178
+ }
179
+
180
+ for (const uid of uids) {
181
+ if (!ordered.includes(uid)) ordered.push(uid);
182
+ }
183
+ return ordered;
184
+ }
185
+
143
186
  function listMediaProfilesToRun() {
144
187
  // Use the media service's own active-profile semantics by delegating
145
188
  // to runActiveProfiles at execute time; here we just need chunk labels.
@@ -153,7 +196,8 @@ module.exports = ({ strapi }) => {
153
196
  const chunks = [];
154
197
 
155
198
  if (scopes.content) {
156
- for (const uid of listSyncableContentTypeUids()) {
199
+ const orderedContentTypes = orderByDependencies(listSyncableContentTypeUids());
200
+ for (const uid of orderedContentTypes) {
157
201
  chunks.push({ kind: 'content', uid, label: uid });
158
202
  }
159
203
  }
@@ -195,6 +195,43 @@ module.exports = ({ strapi }) => {
195
195
  return order;
196
196
  },
197
197
 
198
+ /**
199
+ * Get constrained dependency targets for a content type under one-pass rules:
200
+ * - depth fixed to 1
201
+ * - only direct owner-side relation targets (no mappedBy / inversedBy)
202
+ * - only targets in sync scope (scopeUids set)
203
+ * Returns array of { uid, field, relation } objects.
204
+ */
205
+ getConstrainedDependencyTargets(uid, scopeUids = new Set()) {
206
+ const analysis = this.analyzeContentType(uid);
207
+ const results = [];
208
+
209
+ for (const rel of analysis.relations) {
210
+ // Owner/declaring side only: skip inverse and mapped-by sides
211
+ if (rel.mappedBy || rel.inversedBy) continue;
212
+ // Skip self-references
213
+ if (rel.target === uid) continue;
214
+ // Skip targets not in sync scope
215
+ if (scopeUids.size > 0 && !scopeUids.has(rel.target)) continue;
216
+ // Skip plugin-internal types unless users-permissions
217
+ if (rel.target.startsWith('plugin::') && !rel.target.startsWith('plugin::users-permissions')) continue;
218
+
219
+ results.push({
220
+ uid: rel.target,
221
+ field: rel.field,
222
+ relation: rel.relation,
223
+ });
224
+ }
225
+
226
+ // Deduplicate by target uid (keep first occurrence)
227
+ const seen = new Set();
228
+ return results.filter((r) => {
229
+ if (seen.has(r.uid)) return false;
230
+ seen.add(r.uid);
231
+ return true;
232
+ });
233
+ },
234
+
198
235
  /**
199
236
  * Extract related entity IDs from a record for dependency syncing
200
237
  */
@@ -277,7 +277,8 @@ module.exports = ({ strapi }) => {
277
277
 
278
278
  const executionSettings = await this.getProfileExecutionSettings(profileId);
279
279
  const syncDependencies = options.syncDependencies ?? executionSettings.syncDependencies;
280
- const dependencyDepth = options.dependencyDepth ?? executionSettings.dependencyDepth ?? 1;
280
+ // dependencyDepth is always 1 per strategy constraints regardless of stored setting
281
+ const dependencyDepth = 1;
281
282
 
282
283
  const startTime = new Date();
283
284
  const reportHandle = await syncStatsService.createRunReport({
@@ -299,14 +300,25 @@ module.exports = ({ strapi }) => {
299
300
 
300
301
  const dependencyResults = [];
301
302
  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);
303
+ // Constrained dependency expansion:
304
+ // - depth fixed to 1
305
+ // - owner/declaring side only (no mappedBy/inversedBy traversal)
306
+ // - only targets in sync scope (enabled content types)
307
+ const syncConfigService = plugin().service('syncConfig');
308
+ const syncConfig = await syncConfigService.getSyncConfig();
309
+ const scopeUids = new Set(
310
+ (syncConfig.contentTypes || [])
311
+ .filter((ct) => ct.enabled && ct.uid !== profile.contentType)
312
+ .map((ct) => ct.uid)
313
+ );
314
+
315
+ const constrainedTargets = dependencyResolver.getConstrainedDependencyTargets(
316
+ profile.contentType,
317
+ scopeUids
318
+ );
319
+
320
+ for (const { uid: dependencyUid } of constrainedTargets) {
321
+ const depEnabled = scopeUids.has(dependencyUid);
310
322
  if (!depEnabled) {
311
323
  dependencyResults.push({
312
324
  uid: dependencyUid,
@@ -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
  },