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.
- package/LICENSE +1 -1
- package/README.md +1 -0
- package/admin/src/components/BulkTransferTab.jsx +185 -20
- package/admin/src/components/ContentTypesTab.jsx +28 -1
- package/admin/src/components/LogsTab.jsx +66 -8
- package/admin/src/components/MediaTab.jsx +113 -7
- package/admin/src/components/SyncProfilesTab.jsx +140 -4
- package/admin/src/components/SyncTab.jsx +161 -35
- package/docs/sync-strategy-approach-review.md +127 -0
- package/package.json +1 -1
- package/server/src/services/bulk-transfer.js +45 -1
- package/server/src/services/dependency-resolver.js +37 -0
- package/server/src/services/sync-execution.js +21 -9
- package/server/src/services/sync-profiles.js +36 -15
- package/server/src/services/sync.js +234 -134
- package/server/src/utils/fetcher.js +7 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
:
|
|
89
|
-
|
|
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
|
},
|