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
|
@@ -38,6 +38,11 @@ const CONFLICT_STRATEGY_OPTIONS = [
|
|
|
38
38
|
{ value: 'remote_wins', label: 'Remote Wins' },
|
|
39
39
|
];
|
|
40
40
|
|
|
41
|
+
const EXECUTION_STRATEGY_OPTIONS = [
|
|
42
|
+
{ value: 'hybrid_two_pass', label: 'Hybrid Two-Pass (Recommended)', hint: 'Pass 1: entities. Pass 2: relations from owner side. Most reliable.' },
|
|
43
|
+
{ value: 'one_pass', label: 'One-Pass (Advanced)', hint: 'Single pass. Depth fixed to 1. Only owner-side direct in-scope targets. Less reliable for relation-heavy content.' },
|
|
44
|
+
];
|
|
45
|
+
|
|
41
46
|
const FIELD_DIRECTION_OPTIONS = [
|
|
42
47
|
{ value: 'both', label: 'Both' },
|
|
43
48
|
{ value: 'push', label: 'Push' },
|
|
@@ -64,6 +69,11 @@ const SyncProfilesTab = () => {
|
|
|
64
69
|
const [sortField, setSortField] = useState('name');
|
|
65
70
|
const [sortDirection, setSortDirection] = useState('asc');
|
|
66
71
|
|
|
72
|
+
// Filter state
|
|
73
|
+
const [filterName, setFilterName] = useState('');
|
|
74
|
+
const [filterDirection, setFilterDirection] = useState('');
|
|
75
|
+
const [filterStatus, setFilterStatus] = useState('');
|
|
76
|
+
|
|
67
77
|
// Selection state for bulk operations
|
|
68
78
|
const [selectedProfiles, setSelectedProfiles] = useState([]);
|
|
69
79
|
|
|
@@ -77,6 +87,7 @@ const SyncProfilesTab = () => {
|
|
|
77
87
|
contentType: '',
|
|
78
88
|
direction: 'both',
|
|
79
89
|
conflictStrategy: 'latest',
|
|
90
|
+
executionStrategy: 'hybrid_two_pass',
|
|
80
91
|
syncDeletions: false,
|
|
81
92
|
isActive: false,
|
|
82
93
|
isSimple: true,
|
|
@@ -86,9 +97,26 @@ const SyncProfilesTab = () => {
|
|
|
86
97
|
const [loadingSchema, setLoadingSchema] = useState(false);
|
|
87
98
|
const [syncMode, setSyncMode] = useState('paired');
|
|
88
99
|
|
|
89
|
-
// Sorted profiles
|
|
100
|
+
// Sorted + filtered profiles
|
|
90
101
|
const sortedProfiles = useMemo(() => {
|
|
91
|
-
|
|
102
|
+
let filtered = [...profiles];
|
|
103
|
+
|
|
104
|
+
if (filterName.trim()) {
|
|
105
|
+
const q = filterName.trim().toLowerCase();
|
|
106
|
+
filtered = filtered.filter(
|
|
107
|
+
(p) => p.name.toLowerCase().includes(q) || p.contentType.toLowerCase().includes(q)
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (filterDirection) {
|
|
111
|
+
filtered = filtered.filter((p) => p.direction === filterDirection);
|
|
112
|
+
}
|
|
113
|
+
if (filterStatus) {
|
|
114
|
+
filtered = filtered.filter((p) =>
|
|
115
|
+
filterStatus === 'active' ? p.isActive : !p.isActive
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
filtered.sort((a, b) => {
|
|
92
120
|
let aVal = a[sortField];
|
|
93
121
|
let bVal = b[sortField];
|
|
94
122
|
|
|
@@ -113,8 +141,8 @@ const SyncProfilesTab = () => {
|
|
|
113
141
|
}
|
|
114
142
|
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
|
|
115
143
|
});
|
|
116
|
-
return
|
|
117
|
-
}, [profiles, sortField, sortDirection, contentTypes]);
|
|
144
|
+
return filtered;
|
|
145
|
+
}, [profiles, sortField, sortDirection, contentTypes, filterName, filterDirection, filterStatus]);
|
|
118
146
|
|
|
119
147
|
const handleSort = (field) => {
|
|
120
148
|
if (sortField === field) {
|
|
@@ -291,6 +319,7 @@ const SyncProfilesTab = () => {
|
|
|
291
319
|
contentType: '',
|
|
292
320
|
direction: 'both',
|
|
293
321
|
conflictStrategy: 'latest',
|
|
322
|
+
executionStrategy: 'hybrid_two_pass',
|
|
294
323
|
syncDeletions: false,
|
|
295
324
|
isActive: false,
|
|
296
325
|
isSimple: true,
|
|
@@ -308,6 +337,7 @@ const SyncProfilesTab = () => {
|
|
|
308
337
|
contentType: profile.contentType,
|
|
309
338
|
direction: profile.direction || 'both',
|
|
310
339
|
conflictStrategy: profile.conflictStrategy || 'latest',
|
|
340
|
+
executionStrategy: profile.executionStrategy || 'hybrid_two_pass',
|
|
311
341
|
syncDeletions: !!profile.syncDeletions,
|
|
312
342
|
isActive: profile.isActive,
|
|
313
343
|
isSimple: profile.isSimple !== false,
|
|
@@ -402,6 +432,16 @@ const SyncProfilesTab = () => {
|
|
|
402
432
|
}
|
|
403
433
|
};
|
|
404
434
|
|
|
435
|
+
const handleDeactivate = async (profile) => {
|
|
436
|
+
try {
|
|
437
|
+
await put(`/${PLUGIN_ID}/sync-profiles/${profile.id}`, { isActive: false });
|
|
438
|
+
setMessage({ type: 'success', text: `Deactivated: ${profile.name}` });
|
|
439
|
+
loadData();
|
|
440
|
+
} catch (err) {
|
|
441
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to deactivate profile' });
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
|
|
405
445
|
const getContentTypeName = (uid) => {
|
|
406
446
|
const ct = contentTypes.find((c) => c.uid === uid);
|
|
407
447
|
return ct?.displayName || uid;
|
|
@@ -472,6 +512,55 @@ const SyncProfilesTab = () => {
|
|
|
472
512
|
)}
|
|
473
513
|
|
|
474
514
|
<Box paddingTop={4}>
|
|
515
|
+
{/* Filter Bar */}
|
|
516
|
+
<Flex gap={3} wrap="wrap" marginBottom={4} alignItems="flex-end">
|
|
517
|
+
<Box style={{ flex: '1 1 200px', minWidth: 160 }}>
|
|
518
|
+
<TextInput
|
|
519
|
+
placeholder="Search by name or type…"
|
|
520
|
+
value={filterName}
|
|
521
|
+
onChange={(e) => setFilterName(e.target.value)}
|
|
522
|
+
label="Search"
|
|
523
|
+
size="S"
|
|
524
|
+
/>
|
|
525
|
+
</Box>
|
|
526
|
+
<Box style={{ minWidth: 160 }}>
|
|
527
|
+
<SingleSelect
|
|
528
|
+
placeholder="All directions"
|
|
529
|
+
value={filterDirection}
|
|
530
|
+
onChange={setFilterDirection}
|
|
531
|
+
onClear={() => setFilterDirection('')}
|
|
532
|
+
size="S"
|
|
533
|
+
label="Direction"
|
|
534
|
+
>
|
|
535
|
+
<SingleSelectOption value="push">Push Only</SingleSelectOption>
|
|
536
|
+
<SingleSelectOption value="pull">Pull Only</SingleSelectOption>
|
|
537
|
+
<SingleSelectOption value="both">Bidirectional</SingleSelectOption>
|
|
538
|
+
</SingleSelect>
|
|
539
|
+
</Box>
|
|
540
|
+
<Box style={{ minWidth: 140 }}>
|
|
541
|
+
<SingleSelect
|
|
542
|
+
placeholder="All statuses"
|
|
543
|
+
value={filterStatus}
|
|
544
|
+
onChange={setFilterStatus}
|
|
545
|
+
onClear={() => setFilterStatus('')}
|
|
546
|
+
size="S"
|
|
547
|
+
label="Status"
|
|
548
|
+
>
|
|
549
|
+
<SingleSelectOption value="active">Active</SingleSelectOption>
|
|
550
|
+
<SingleSelectOption value="inactive">Inactive</SingleSelectOption>
|
|
551
|
+
</SingleSelect>
|
|
552
|
+
</Box>
|
|
553
|
+
{(filterName || filterDirection || filterStatus) && (
|
|
554
|
+
<Button
|
|
555
|
+
variant="tertiary"
|
|
556
|
+
size="S"
|
|
557
|
+
onClick={() => { setFilterName(''); setFilterDirection(''); setFilterStatus(''); }}
|
|
558
|
+
>
|
|
559
|
+
Clear filters
|
|
560
|
+
</Button>
|
|
561
|
+
)}
|
|
562
|
+
</Flex>
|
|
563
|
+
|
|
475
564
|
{profiles.length === 0 ? (
|
|
476
565
|
<Box padding={6} background="neutral0" hasRadius>
|
|
477
566
|
<Typography textColor="neutral600">
|
|
@@ -479,6 +568,10 @@ const SyncProfilesTab = () => {
|
|
|
479
568
|
or create a custom profile.
|
|
480
569
|
</Typography>
|
|
481
570
|
</Box>
|
|
571
|
+
) : sortedProfiles.length === 0 ? (
|
|
572
|
+
<Box padding={6} background="neutral0" hasRadius>
|
|
573
|
+
<Typography textColor="neutral600">No profiles match the current filters.</Typography>
|
|
574
|
+
</Box>
|
|
482
575
|
) : (
|
|
483
576
|
<Table>
|
|
484
577
|
<Thead>
|
|
@@ -523,6 +616,12 @@ const SyncProfilesTab = () => {
|
|
|
523
616
|
<Badge active={!profile.isSimple}>
|
|
524
617
|
{profile.isSimple ? 'Simple' : 'Advanced'}
|
|
525
618
|
</Badge>
|
|
619
|
+
{' '}
|
|
620
|
+
<Badge active={profile.executionStrategy !== 'one_pass'} title={
|
|
621
|
+
profile.executionStrategy === 'one_pass' ? 'One-Pass (depth=1, owner-side only)' : 'Hybrid Two-Pass (entities then relations)'
|
|
622
|
+
}>
|
|
623
|
+
{profile.executionStrategy === 'one_pass' ? '1-Pass' : '2-Pass'}
|
|
624
|
+
</Badge>
|
|
526
625
|
</Td>
|
|
527
626
|
<Td>
|
|
528
627
|
{profile.isActive ? (
|
|
@@ -533,6 +632,23 @@ const SyncProfilesTab = () => {
|
|
|
533
632
|
</Td>
|
|
534
633
|
<Td>
|
|
535
634
|
<Flex gap={1}>
|
|
635
|
+
{profile.isActive ? (
|
|
636
|
+
<Button
|
|
637
|
+
variant="tertiary"
|
|
638
|
+
size="S"
|
|
639
|
+
onClick={() => handleDeactivate(profile)}
|
|
640
|
+
>
|
|
641
|
+
Deactivate
|
|
642
|
+
</Button>
|
|
643
|
+
) : (
|
|
644
|
+
<Button
|
|
645
|
+
variant="success"
|
|
646
|
+
size="S"
|
|
647
|
+
onClick={() => handleActivate(profile)}
|
|
648
|
+
>
|
|
649
|
+
Activate
|
|
650
|
+
</Button>
|
|
651
|
+
)}
|
|
536
652
|
<IconButton label="Edit" onClick={() => openEditModal(profile)}>
|
|
537
653
|
<Pencil />
|
|
538
654
|
</IconButton>
|
|
@@ -669,6 +785,26 @@ const SyncProfilesTab = () => {
|
|
|
669
785
|
</Field.Root>
|
|
670
786
|
</Box>
|
|
671
787
|
|
|
788
|
+
{/* Execution Strategy */}
|
|
789
|
+
<Box paddingBottom={4}>
|
|
790
|
+
<Field.Root>
|
|
791
|
+
<Field.Label>Execution Strategy</Field.Label>
|
|
792
|
+
<SingleSelect
|
|
793
|
+
value={formData.executionStrategy || 'hybrid_two_pass'}
|
|
794
|
+
onChange={(value) => setFormData((p) => ({ ...p, executionStrategy: value }))}
|
|
795
|
+
>
|
|
796
|
+
{EXECUTION_STRATEGY_OPTIONS.map((opt) => (
|
|
797
|
+
<SingleSelectOption key={opt.value} value={opt.value}>
|
|
798
|
+
{opt.label}
|
|
799
|
+
</SingleSelectOption>
|
|
800
|
+
))}
|
|
801
|
+
</SingleSelect>
|
|
802
|
+
<Field.Hint>
|
|
803
|
+
{EXECUTION_STRATEGY_OPTIONS.find(o => o.value === (formData.executionStrategy || 'hybrid_two_pass'))?.hint}
|
|
804
|
+
</Field.Hint>
|
|
805
|
+
</Field.Root>
|
|
806
|
+
</Box>
|
|
807
|
+
|
|
672
808
|
{/* Deletions Toggle */}
|
|
673
809
|
<Box paddingBottom={4}>
|
|
674
810
|
<Checkbox
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
Checkbox,
|
|
24
24
|
TextInput,
|
|
25
25
|
} from '@strapi/design-system';
|
|
26
|
-
import { Play, Clock, Cog, ArrowUp, ArrowDown } from '@strapi/icons';
|
|
26
|
+
import { Play, Clock, Cog, ArrowUp, ArrowDown, CaretUp, CaretDown } from '@strapi/icons';
|
|
27
27
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
28
28
|
|
|
29
29
|
|
|
@@ -59,9 +59,19 @@ const SyncTab = () => {
|
|
|
59
59
|
|
|
60
60
|
// Filter and ordering
|
|
61
61
|
const [profileFilter, setProfileFilter] = useState('all');
|
|
62
|
+
const [profileSearch, setProfileSearch] = useState('');
|
|
62
63
|
const [executionOrder, setExecutionOrder] = useState({}); // { profileId: order }
|
|
63
64
|
const [orderModified, setOrderModified] = useState(false);
|
|
64
65
|
|
|
66
|
+
// Sort state for Execute table
|
|
67
|
+
const [execSortField, setExecSortField] = useState('');
|
|
68
|
+
const [execSortDir, setExecSortDir] = useState('asc');
|
|
69
|
+
|
|
70
|
+
// Sort + search state for Status table
|
|
71
|
+
const [statusSearch, setStatusSearch] = useState('');
|
|
72
|
+
const [statusSortField, setStatusSortField] = useState('');
|
|
73
|
+
const [statusSortDir, setStatusSortDir] = useState('asc');
|
|
74
|
+
|
|
65
75
|
// Selection for batch execution
|
|
66
76
|
const [selectedProfiles, setSelectedProfiles] = useState([]);
|
|
67
77
|
|
|
@@ -85,12 +95,25 @@ const SyncTab = () => {
|
|
|
85
95
|
loadData();
|
|
86
96
|
}, []);
|
|
87
97
|
|
|
98
|
+
// Refresh data whenever this browser tab regains focus (e.g. user activated a
|
|
99
|
+
// profile in the Sync Profiles tab and switches back here).
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const onVisibility = () => {
|
|
102
|
+
if (document.visibilityState === 'visible') {
|
|
103
|
+
loadData();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
107
|
+
return () => document.removeEventListener('visibilitychange', onVisibility);
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
88
110
|
const loadData = async () => {
|
|
89
111
|
try {
|
|
90
112
|
const [profilesRes, statusRes, globalRes, depsRes, configRes] = await Promise.all([
|
|
91
113
|
get(`/${PLUGIN_ID}/sync-profiles`),
|
|
92
114
|
get(`/${PLUGIN_ID}/sync-execution/status`),
|
|
93
115
|
get(`/${PLUGIN_ID}/sync-execution/global-settings`),
|
|
116
|
+
// dependencies are recomputed from enabled types each load
|
|
94
117
|
get(`/${PLUGIN_ID}/dependencies/all`).catch(() => ({ data: { data: {} } })),
|
|
95
118
|
get(`/${PLUGIN_ID}/config`),
|
|
96
119
|
]);
|
|
@@ -407,7 +430,7 @@ const SyncTab = () => {
|
|
|
407
430
|
return { dependsOn, dependedBy };
|
|
408
431
|
};
|
|
409
432
|
|
|
410
|
-
// Filter and sort profiles
|
|
433
|
+
// Filter and sort profiles for Execute table
|
|
411
434
|
const filteredProfiles = useMemo(() => {
|
|
412
435
|
let result = [...profiles];
|
|
413
436
|
|
|
@@ -416,18 +439,87 @@ const SyncTab = () => {
|
|
|
416
439
|
result = result.filter(p => p.isActive);
|
|
417
440
|
}
|
|
418
441
|
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
442
|
+
// Apply name search
|
|
443
|
+
if (profileSearch.trim()) {
|
|
444
|
+
const q = profileSearch.trim().toLowerCase();
|
|
445
|
+
result = result.filter(
|
|
446
|
+
(p) => p.name.toLowerCase().includes(q) || p.contentType.toLowerCase().includes(q)
|
|
447
|
+
);
|
|
448
|
+
}
|
|
425
449
|
|
|
450
|
+
// Sort by explicit column if chosen, else by execution order
|
|
451
|
+
if (execSortField) {
|
|
452
|
+
result.sort((a, b) => {
|
|
453
|
+
let aVal = a[execSortField] ?? '';
|
|
454
|
+
let bVal = b[execSortField] ?? '';
|
|
455
|
+
if (typeof aVal === 'boolean') { aVal = aVal ? 1 : 0; bVal = bVal ? 1 : 0; }
|
|
456
|
+
if (typeof aVal === 'string') {
|
|
457
|
+
return execSortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
458
|
+
}
|
|
459
|
+
return execSortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
460
|
+
});
|
|
461
|
+
} else {
|
|
462
|
+
// Sort by execution order
|
|
463
|
+
result.sort((a, b) => {
|
|
464
|
+
const orderA = executionOrder[a.id] || 999;
|
|
465
|
+
const orderB = executionOrder[b.id] || 999;
|
|
466
|
+
return orderA - orderB;
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return result;
|
|
471
|
+
}, [profiles, profileFilter, profileSearch, executionOrder, execSortField, execSortDir]);
|
|
472
|
+
|
|
473
|
+
// Filter and sort for Status table
|
|
474
|
+
const filteredStatus = useMemo(() => {
|
|
475
|
+
let result = [...executionStatus];
|
|
476
|
+
if (statusSearch.trim()) {
|
|
477
|
+
const q = statusSearch.trim().toLowerCase();
|
|
478
|
+
result = result.filter((s) => (s.profileName || '').toLowerCase().includes(q));
|
|
479
|
+
}
|
|
480
|
+
if (statusSortField) {
|
|
481
|
+
result.sort((a, b) => {
|
|
482
|
+
let aVal = a[statusSortField] ?? '';
|
|
483
|
+
let bVal = b[statusSortField] ?? '';
|
|
484
|
+
if (typeof aVal === 'boolean') { aVal = aVal ? 1 : 0; bVal = bVal ? 1 : 0; }
|
|
485
|
+
if (typeof aVal === 'string') {
|
|
486
|
+
return statusSortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
487
|
+
}
|
|
488
|
+
return statusSortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
489
|
+
});
|
|
490
|
+
}
|
|
426
491
|
return result;
|
|
427
|
-
}, [
|
|
492
|
+
}, [executionStatus, statusSearch, statusSortField, statusSortDir]);
|
|
428
493
|
|
|
429
494
|
const activeProfilesInFilter = filteredProfiles.filter(p => p.isActive);
|
|
430
495
|
|
|
496
|
+
const handleExecSort = (field) => {
|
|
497
|
+
if (execSortField === field) {
|
|
498
|
+
setExecSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
499
|
+
} else {
|
|
500
|
+
setExecSortField(field);
|
|
501
|
+
setExecSortDir('asc');
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const handleStatusSort = (field) => {
|
|
506
|
+
if (statusSortField === field) {
|
|
507
|
+
setStatusSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
508
|
+
} else {
|
|
509
|
+
setStatusSortField(field);
|
|
510
|
+
setStatusSortDir('asc');
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const SortableTh = ({ field, sortF, sortD, onSort, children }) => (
|
|
515
|
+
<Th onClick={() => onSort(field)} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
|
516
|
+
<Flex alignItems="center" gap={1}>
|
|
517
|
+
<Typography variant="sigma">{children}</Typography>
|
|
518
|
+
{sortF === field && (sortD === 'asc' ? <CaretUp /> : <CaretDown />)}
|
|
519
|
+
</Flex>
|
|
520
|
+
</Th>
|
|
521
|
+
);
|
|
522
|
+
|
|
431
523
|
if (loading) return <Typography>Loading…</Typography>;
|
|
432
524
|
|
|
433
525
|
return (
|
|
@@ -463,6 +555,9 @@ const SyncTab = () => {
|
|
|
463
555
|
<Button onClick={handleSyncAll} loading={syncing} disabled={syncing}>
|
|
464
556
|
{syncing ? 'Syncing…' : 'Sync All Active'}
|
|
465
557
|
</Button>
|
|
558
|
+
<Button variant="tertiary" onClick={loadData} disabled={loading}>
|
|
559
|
+
Refresh
|
|
560
|
+
</Button>
|
|
466
561
|
</Flex>
|
|
467
562
|
</Flex>
|
|
468
563
|
|
|
@@ -508,21 +603,31 @@ const SyncTab = () => {
|
|
|
508
603
|
|
|
509
604
|
<Box paddingTop={4}>
|
|
510
605
|
{/* Filter and Order Controls */}
|
|
511
|
-
<Flex justifyContent="space-between" alignItems="
|
|
512
|
-
<Flex gap={
|
|
606
|
+
<Flex justifyContent="space-between" alignItems="flex-end" marginBottom={4} wrap="wrap" gap={3}>
|
|
607
|
+
<Flex gap={3} alignItems="flex-end" wrap="wrap">
|
|
513
608
|
<Typography variant="delta">Profiles</Typography>
|
|
514
|
-
<
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
609
|
+
<Box style={{ minWidth: 160 }}>
|
|
610
|
+
<SingleSelect
|
|
611
|
+
value={profileFilter}
|
|
612
|
+
onChange={setProfileFilter}
|
|
613
|
+
size="S"
|
|
614
|
+
>
|
|
615
|
+
{FILTER_OPTIONS.map(opt => (
|
|
616
|
+
<SingleSelectOption key={opt.value} value={opt.value}>
|
|
617
|
+
{opt.label}
|
|
618
|
+
</SingleSelectOption>
|
|
619
|
+
))}
|
|
620
|
+
</SingleSelect>
|
|
621
|
+
</Box>
|
|
622
|
+
<Box style={{ minWidth: 200 }}>
|
|
623
|
+
<TextInput
|
|
624
|
+
placeholder="Search by name or type…"
|
|
625
|
+
value={profileSearch}
|
|
626
|
+
onChange={(e) => setProfileSearch(e.target.value)}
|
|
627
|
+
size="S"
|
|
628
|
+
label=""
|
|
629
|
+
/>
|
|
630
|
+
</Box>
|
|
526
631
|
</Flex>
|
|
527
632
|
<Flex gap={2}>
|
|
528
633
|
{orderModified && (
|
|
@@ -544,8 +649,10 @@ const SyncTab = () => {
|
|
|
544
649
|
<Box padding={4} background="neutral0" hasRadius>
|
|
545
650
|
<Typography textColor="neutral600">
|
|
546
651
|
{profileFilter === 'active'
|
|
547
|
-
? 'No active profiles. Activate a profile in the Sync Profiles tab first.'
|
|
548
|
-
:
|
|
652
|
+
? 'No active profiles match the search. Activate a profile in the Sync Profiles tab first.'
|
|
653
|
+
: profileSearch
|
|
654
|
+
? 'No profiles match the search.'
|
|
655
|
+
: 'No profiles found. Create a profile in the Sync Profiles tab.'}
|
|
549
656
|
</Typography>
|
|
550
657
|
</Box>
|
|
551
658
|
) : (
|
|
@@ -561,10 +668,10 @@ const SyncTab = () => {
|
|
|
561
668
|
/>
|
|
562
669
|
</Th>
|
|
563
670
|
<Th style={{ width: 80 }}><Typography variant="sigma">Order</Typography></Th>
|
|
564
|
-
<
|
|
565
|
-
<
|
|
671
|
+
<SortableTh field="name" sortF={execSortField} sortD={execSortDir} onSort={handleExecSort}>Profile</SortableTh>
|
|
672
|
+
<SortableTh field="contentType" sortF={execSortField} sortD={execSortDir} onSort={handleExecSort}>Content Type</SortableTh>
|
|
566
673
|
<Th><Typography variant="sigma">Dependencies</Typography></Th>
|
|
567
|
-
<
|
|
674
|
+
<SortableTh field="isActive" sortF={execSortField} sortD={execSortDir} onSort={handleExecSort}>Status</SortableTh>
|
|
568
675
|
<Th><Typography variant="sigma">Execution Mode</Typography></Th>
|
|
569
676
|
<Th><Typography variant="sigma">Actions</Typography></Th>
|
|
570
677
|
</Tr>
|
|
@@ -614,7 +721,7 @@ const SyncTab = () => {
|
|
|
614
721
|
<TextInput
|
|
615
722
|
value={order}
|
|
616
723
|
onChange={(e) => handleOrderChange(profile.id, e.target.value)}
|
|
617
|
-
style={{ width:
|
|
724
|
+
style={{ width: 80, textAlign: 'center' }}
|
|
618
725
|
size="S"
|
|
619
726
|
type="number"
|
|
620
727
|
min={1}
|
|
@@ -719,20 +826,39 @@ const SyncTab = () => {
|
|
|
719
826
|
Monitor scheduled and live sync jobs.
|
|
720
827
|
</Typography>
|
|
721
828
|
|
|
722
|
-
<Box paddingTop={4}>
|
|
829
|
+
<Box paddingTop={4} paddingBottom={3}>
|
|
830
|
+
<TextInput
|
|
831
|
+
placeholder="Search by profile name…"
|
|
832
|
+
value={statusSearch}
|
|
833
|
+
onChange={(e) => setStatusSearch(e.target.value)}
|
|
834
|
+
label="Search"
|
|
835
|
+
size="S"
|
|
836
|
+
style={{ maxWidth: 280 }}
|
|
837
|
+
/>
|
|
838
|
+
</Box>
|
|
839
|
+
|
|
840
|
+
<Box paddingTop={2}>
|
|
723
841
|
<Table>
|
|
724
842
|
<Thead>
|
|
725
843
|
<Tr>
|
|
726
|
-
<
|
|
727
|
-
<
|
|
728
|
-
<
|
|
729
|
-
<
|
|
844
|
+
<SortableTh field="profileName" sortF={statusSortField} sortD={statusSortDir} onSort={handleStatusSort}>Profile</SortableTh>
|
|
845
|
+
<SortableTh field="executionMode" sortF={statusSortField} sortD={statusSortDir} onSort={handleStatusSort}>Mode</SortableTh>
|
|
846
|
+
<SortableTh field="enabled" sortF={statusSortField} sortD={statusSortDir} onSort={handleStatusSort}>Enabled</SortableTh>
|
|
847
|
+
<SortableTh field="lastExecutedAt" sortF={statusSortField} sortD={statusSortDir} onSort={handleStatusSort}>Last Run</SortableTh>
|
|
730
848
|
<Th><Typography variant="sigma">Next Run</Typography></Th>
|
|
731
849
|
<Th><Typography variant="sigma">Status</Typography></Th>
|
|
732
850
|
</Tr>
|
|
733
851
|
</Thead>
|
|
734
852
|
<Tbody>
|
|
735
|
-
{
|
|
853
|
+
{filteredStatus.length === 0 ? (
|
|
854
|
+
<Tr>
|
|
855
|
+
<Td colSpan={6}>
|
|
856
|
+
<Typography textColor="neutral500">
|
|
857
|
+
{statusSearch ? 'No profiles match the search.' : 'No execution status found.'}
|
|
858
|
+
</Typography>
|
|
859
|
+
</Td>
|
|
860
|
+
</Tr>
|
|
861
|
+
) : filteredStatus.map((status) => (
|
|
736
862
|
<Tr key={status.profileId}>
|
|
737
863
|
<Td>
|
|
738
864
|
<Typography fontWeight="bold">{status.profileName}</Typography>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Sync Strategy Approach Review
|
|
2
|
+
|
|
3
|
+
This document consolidates and refines the agreed approach for sync reliability, dependency handling, content-type enabling, and profile editing.
|
|
4
|
+
|
|
5
|
+
## 1) Core Execution Strategy (Hybrid Two-Pass)
|
|
6
|
+
|
|
7
|
+
**Consolidated rule:** Use a hybrid two-pass sync approach: **pass 1 syncs core entities, pass 2 syncs one-direction dependencies from owner/declaring side only** (entities first, relations second).
|
|
8
|
+
|
|
9
|
+
Implementation shape:
|
|
10
|
+
|
|
11
|
+
1. **Pass 1: Entities First**
|
|
12
|
+
- Sync core entity payload first (materialize records on both sides).
|
|
13
|
+
- Avoid relation-linking behavior in this pass.
|
|
14
|
+
2. **Pass 2: Relations Second (One Direction)**
|
|
15
|
+
- Sync dependencies/relations only after entities exist.
|
|
16
|
+
- Apply relation sync in one direction from the **owner/declaring side only**.
|
|
17
|
+
|
|
18
|
+
### Media in the Same Two-Pass Model
|
|
19
|
+
|
|
20
|
+
Media follows the same strategy:
|
|
21
|
+
|
|
22
|
+
1. **Pass 1 (Core media):** sync media entities/files first.
|
|
23
|
+
2. **Pass 2 (Media links):** sync media relations from owner entities (content types that hold media fields).
|
|
24
|
+
|
|
25
|
+
Design decision:
|
|
26
|
+
|
|
27
|
+
- Treat media as a referenced target, not the relation-driving owner.
|
|
28
|
+
- Relation updates are written from owning entities only.
|
|
29
|
+
- Remove separate morph-side traversal/update strategy from the approach.
|
|
30
|
+
|
|
31
|
+
Why: this removes duplicate/bi-directional link work, reduces conflict risk, and keeps relation application deterministic.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 2) Dependency Rules (Authoritative)
|
|
36
|
+
|
|
37
|
+
For dependency sync:
|
|
38
|
+
|
|
39
|
+
1. `dependencyDepth` is always **1**.
|
|
40
|
+
2. Include only dependency targets that are part of sync scope.
|
|
41
|
+
3. Never traverse owning-side metadata graph expansion via `mappedBy` / `inversedBy`.
|
|
42
|
+
4. In pass 2, apply relation updates from the **owner/declaring side only**.
|
|
43
|
+
|
|
44
|
+
Applied interpretation:
|
|
45
|
+
|
|
46
|
+
- Use direct relation target only.
|
|
47
|
+
- Exclude self-links and out-of-scope content types.
|
|
48
|
+
- No recursive traversal.
|
|
49
|
+
- No inverse-side fan-out traversal.
|
|
50
|
+
- Media links are applied only from owner entities; no separate morph-driven inverse traversal.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 3) Execution Ordering
|
|
55
|
+
|
|
56
|
+
- Entities pass runs first for all selected content types and core media.
|
|
57
|
+
- Relations pass runs second for all selected content types, including media link fields from owner entities.
|
|
58
|
+
- Ordering remains dependency-aware and stable.
|
|
59
|
+
- For relation-heavy types, keep lower priority (higher order number) where ordering tie-break is needed.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 4) Enabling Content Types
|
|
64
|
+
|
|
65
|
+
When enabling a content type, provide:
|
|
66
|
+
|
|
67
|
+
1. **Enable only selected type**
|
|
68
|
+
2. **Enable selected type + direct dependencies (depth=1)**
|
|
69
|
+
|
|
70
|
+
Behavior for “enable with dependencies”:
|
|
71
|
+
|
|
72
|
+
- Expand only direct in-scope relation targets.
|
|
73
|
+
- Do not recursively traverse.
|
|
74
|
+
- Do not traverse through `mappedBy` / `inversedBy`.
|
|
75
|
+
|
|
76
|
+
Recommended UX:
|
|
77
|
+
|
|
78
|
+
- Show preview summary before apply:
|
|
79
|
+
- to enable
|
|
80
|
+
- already enabled
|
|
81
|
+
- skipped/out-of-scope
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 5) Sync Profiles and Advanced Editing
|
|
86
|
+
|
|
87
|
+
Profiles should remain fully editable and not locked by auto-generation.
|
|
88
|
+
|
|
89
|
+
For newly enabled types, support:
|
|
90
|
+
|
|
91
|
+
1. **Quick defaults** (auto-create profiles)
|
|
92
|
+
2. **Create + edit now** (guided advanced configuration)
|
|
93
|
+
|
|
94
|
+
Editable advanced settings include:
|
|
95
|
+
|
|
96
|
+
- direction
|
|
97
|
+
- conflict strategy
|
|
98
|
+
- sync deletions
|
|
99
|
+
- execution mode
|
|
100
|
+
- dependency sync toggle
|
|
101
|
+
- dependency depth (fixed to 1 where dependency sync is used)
|
|
102
|
+
- field-level policies
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 6) Suggested Implementation Sequence
|
|
107
|
+
|
|
108
|
+
1. Add strategy contract as **hybrid two-pass default**.
|
|
109
|
+
2. Enforce dependency constraints globally (depth=1, in-scope only, no mappedBy/inversedBy traversal).
|
|
110
|
+
3. Implement orchestration in sync-now, profile execution, and bulk transfer:
|
|
111
|
+
- pass 1 entities + core media
|
|
112
|
+
- pass 2 owner-side relations (including media links from owner entities)
|
|
113
|
+
- remove separate morph/inverse traversal flow
|
|
114
|
+
4. Add content-type enable flow with “enable dependencies too” option and preview.
|
|
115
|
+
5. Keep profile editing fully available, including advanced settings.
|
|
116
|
+
6. Update UI hints to explain constraints and reliability tradeoffs.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 7) Final Position
|
|
121
|
+
|
|
122
|
+
- Use **hybrid two-pass** execution: entities/core media first, relations second.
|
|
123
|
+
- Relation sync in pass 2 is **one directional from owner/declaring side**.
|
|
124
|
+
- Media links are synced from owner entities only; no separate morph-side inverse traversal strategy.
|
|
125
|
+
- Keep dependency scope constrained (depth=1, in-scope targets only).
|
|
126
|
+
- Add dependency-aware enable-all-direct-dependencies option.
|
|
127
|
+
- Preserve and improve advanced profile editing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strapi-content-sync-pro",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Strapi v5 plugin to copy, migrate, and live-sync content, media, and data between multiple Strapi environments with bi-directional sync, field-level policies, scheduling, and alerts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|