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.
- package/LICENSE +1 -1
- package/README.md +32 -14
- package/admin/src/components/BulkTransferTab.jsx +185 -20
- package/admin/src/components/ConfigTab.jsx +81 -3
- package/admin/src/components/ContentTypesTab.jsx +28 -1
- package/admin/src/components/HelpTab.jsx +34 -0
- package/admin/src/components/LogsTab.jsx +66 -8
- package/admin/src/components/MediaTab.jsx +253 -36
- package/admin/src/components/SyncProfilesTab.jsx +140 -4
- package/admin/src/components/SyncTab.jsx +161 -35
- package/docs/Screenshot 2026-04-22 183540.png +0 -0
- package/docs/Screenshot 2026-04-22 183552.png +0 -0
- package/docs/Screenshot 2026-04-23 114332.png +0 -0
- package/docs/Screenshot 2026-04-23 114644.png +0 -0
- package/docs/Screenshot 2026-04-23 114651.png +0 -0
- package/docs/Screenshot 2026-04-23 114737.png +0 -0
- package/docs/Screenshot 2026-04-23 114904.png +0 -0
- package/docs/Screenshot 2026-04-23 114940.png +0 -0
- package/docs/Screenshot 2026-04-23 115003.png +0 -0
- package/docs/Screenshot 2026-04-23 115024.png +0 -0
- package/docs/Screenshot 2026-04-23 115116.png +0 -0
- package/docs/Screenshot 2026-04-23 115141.png +0 -0
- package/docs/Screenshot 2026-04-23 115252.png +0 -0
- package/docs/Screenshot 2026-04-23 115448.png +0 -0
- package/docs/Screenshot 2026-04-23 120534.png +0 -0
- package/docs/Screenshot 2026-04-23 122544.png +0 -0
- package/docs/Screenshot 2026-04-23 122712.png +0 -0
- package/docs/Screenshot 2026-04-23 122730.png +0 -0
- package/docs/Screenshot 2026-04-23 122858.png +0 -0
- package/docs/Screenshot 2026-04-23 122924.png +0 -0
- package/docs/Screenshot 2026-04-23 122937.png +0 -0
- package/docs/sync-strategy-approach-review.md +127 -0
- package/package.json +1 -1
- package/server/src/controllers/config.js +76 -3
- package/server/src/controllers/sync-media.js +24 -0
- package/server/src/routes/index.js +3 -0
- 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-media.js +168 -32
- 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
- package/docs/Screenshot 2026-04-20 160506.png +0 -0
- package/docs/Screenshot 2026-04-20 160558.png +0 -0
- package/docs/Screenshot 2026-04-20 175903.png +0 -0
- package/docs/Screenshot 2026-04-20 175931.png +0 -0
- package/docs/Screenshot 2026-04-20 180001.png +0 -0
- package/docs/Screenshot 2026-04-20 180041.png +0 -0
- package/docs/Screenshot 2026-04-20 180116.png +0 -0
- package/docs/Screenshot 2026-04-20 180135.png +0 -0
- package/docs/Screenshot 2026-04-20 180202.png +0 -0
- package/docs/Screenshot 2026-04-20 180228.png +0 -0
- package/docs/Screenshot 2026-04-20 180251.png +0 -0
- package/docs/Screenshot 2026-04-20 180301.png +0 -0
- package/docs/clipchamp-screen-recording-script.md +0 -0
- package/docs/production-readiness-status.md +0 -34
- package/docs/production-readiness-test-matrix.md +0 -151
- package/docs/test-environments-setup-legacy.txt +0 -60
|
@@ -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": {
|
|
@@ -198,12 +198,69 @@ module.exports = {
|
|
|
198
198
|
* Proxy login to remote Strapi and retrieve/create API token
|
|
199
199
|
*/
|
|
200
200
|
async remoteLogin(ctx) {
|
|
201
|
-
const { baseUrl, email, password } = ctx.request.body;
|
|
201
|
+
const { baseUrl: rawBaseUrl, email, password } = ctx.request.body;
|
|
202
202
|
|
|
203
|
-
if (!
|
|
203
|
+
if (!rawBaseUrl || !email || !password) {
|
|
204
204
|
return ctx.badRequest('baseUrl, email, and password are required');
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// --- Normalize and validate the base URL so URL mistakes produce a clear message ---
|
|
208
|
+
let baseUrl = String(rawBaseUrl).trim();
|
|
209
|
+
if (!/^https?:\/\//i.test(baseUrl)) {
|
|
210
|
+
return ctx.badRequest(
|
|
211
|
+
`Invalid Server URL "${rawBaseUrl}": must start with http:// or https:// (e.g. http://localhost:4010)`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
// Strip trailing slashes and accidental /admin suffix
|
|
215
|
+
baseUrl = baseUrl.replace(/\/+$/, '').replace(/\/admin$/i, '');
|
|
216
|
+
let parsedUrl;
|
|
217
|
+
try {
|
|
218
|
+
parsedUrl = new URL(baseUrl);
|
|
219
|
+
} catch {
|
|
220
|
+
return ctx.badRequest(
|
|
221
|
+
`Invalid Server URL "${rawBaseUrl}": not a valid URL. Expected format: http(s)://host[:port]`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (!parsedUrl.hostname) {
|
|
225
|
+
return ctx.badRequest(`Invalid Server URL "${rawBaseUrl}": missing hostname`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- Pre-flight reachability check against /admin/init so wrong URL != "invalid credentials" ---
|
|
229
|
+
try {
|
|
230
|
+
const initResp = await fetch(`${baseUrl}/admin/init`, { method: 'GET' });
|
|
231
|
+
if (!initResp.ok) {
|
|
232
|
+
return ctx.throw(
|
|
233
|
+
502,
|
|
234
|
+
`Server URL "${baseUrl}" is reachable but did not respond as a Strapi admin (HTTP ${initResp.status} on /admin/init). Check that the URL points to the root of a Strapi v5 server (no /admin suffix) and that the admin panel is enabled.`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const initData = await initResp.json().catch(() => null);
|
|
238
|
+
if (!initData || typeof initData !== 'object' || !('data' in initData)) {
|
|
239
|
+
return ctx.throw(
|
|
240
|
+
502,
|
|
241
|
+
`Server URL "${baseUrl}" did not return a valid Strapi /admin/init response. Please verify the URL points to a Strapi v5 instance.`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (initData.data && initData.data.hasAdmin === false) {
|
|
245
|
+
return ctx.throw(
|
|
246
|
+
400,
|
|
247
|
+
`Remote Strapi at "${baseUrl}" has no admin user yet. Create the first admin in the remote Strapi panel before generating a token.`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if (err && err.status) throw err;
|
|
252
|
+
const code = err && (err.cause?.code || err.code);
|
|
253
|
+
const hint =
|
|
254
|
+
code === 'ECONNREFUSED'
|
|
255
|
+
? 'Connection refused — the server is not running or not listening on that port.'
|
|
256
|
+
: code === 'ENOTFOUND'
|
|
257
|
+
? 'Host not found — check the hostname/IP spelling and that it resolves from this machine.'
|
|
258
|
+
: code === 'ETIMEDOUT'
|
|
259
|
+
? 'Request timed out — check firewall, network, and that the port is reachable.'
|
|
260
|
+
: 'Network error while contacting the remote server.';
|
|
261
|
+
return ctx.throw(502, `Cannot reach "${baseUrl}": ${hint}${code ? ` (${code})` : ''}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
207
264
|
try {
|
|
208
265
|
// Step 1: Login to remote Strapi admin
|
|
209
266
|
const loginResponse = await fetch(`${baseUrl}/admin/login`, {
|
|
@@ -219,7 +276,23 @@ module.exports = {
|
|
|
219
276
|
|
|
220
277
|
if (!loginResponse.ok) {
|
|
221
278
|
const errorBody = await loginResponse.json().catch(() => ({}));
|
|
222
|
-
const
|
|
279
|
+
const remoteMsg = errorBody?.error?.message;
|
|
280
|
+
// Distinguish URL-vs-credential failures so users aren't misled
|
|
281
|
+
if (loginResponse.status === 404) {
|
|
282
|
+
return ctx.throw(
|
|
283
|
+
404,
|
|
284
|
+
`"${baseUrl}/admin/login" not found (HTTP 404). The Server URL likely points to the wrong path — use the Strapi root URL (e.g. http://localhost:4010), not an admin or API sub-path.`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (loginResponse.status === 405) {
|
|
288
|
+
return ctx.throw(
|
|
289
|
+
405,
|
|
290
|
+
`"${baseUrl}/admin/login" rejected the request method. The Server URL may be pointing to a proxy or non-Strapi endpoint.`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const errorMessage = remoteMsg
|
|
294
|
+
? `Remote login failed: ${remoteMsg}. If you believe the credentials are correct, double-check the Server URL "${baseUrl}" is the right Strapi instance.`
|
|
295
|
+
: `Login failed with status ${loginResponse.status} at ${baseUrl}/admin/login`;
|
|
223
296
|
return ctx.throw(loginResponse.status, errorMessage);
|
|
224
297
|
}
|
|
225
298
|
|
|
@@ -111,6 +111,30 @@ module.exports = ({ strapi }) => ({
|
|
|
111
111
|
}
|
|
112
112
|
},
|
|
113
113
|
|
|
114
|
+
async pauseProfile(ctx) {
|
|
115
|
+
try {
|
|
116
|
+
ctx.body = { data: await service(strapi).pauseProfile(ctx.params.id) };
|
|
117
|
+
} catch (err) {
|
|
118
|
+
ctx.throw(400, err.message);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async resumeProfile(ctx) {
|
|
123
|
+
try {
|
|
124
|
+
ctx.body = { data: await service(strapi).resumeProfile(ctx.params.id) };
|
|
125
|
+
} catch (err) {
|
|
126
|
+
ctx.throw(400, err.message);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async cancelProfile(ctx) {
|
|
131
|
+
try {
|
|
132
|
+
ctx.body = { data: await service(strapi).cancelProfile(ctx.params.id) };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
ctx.throw(400, err.message);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
|
|
114
138
|
// ── Morph link sync (documentId-based mapping) ───────────────────────────
|
|
115
139
|
|
|
116
140
|
async getMorphLinks(ctx) {
|
|
@@ -91,6 +91,9 @@ const adminRoutes = [
|
|
|
91
91
|
{ method: 'DELETE', path: '/media-sync/profiles/:id', handler: 'syncMedia.deleteProfile', config: { policies: [] } },
|
|
92
92
|
{ method: 'POST', path: '/media-sync/profiles/:id/activate', handler: 'syncMedia.activateProfile', config: { policies: [] } },
|
|
93
93
|
{ method: 'POST', path: '/media-sync/profiles/:id/run', handler: 'syncMedia.runProfile', config: { policies: [] } },
|
|
94
|
+
{ method: 'POST', path: '/media-sync/profiles/:id/pause', handler: 'syncMedia.pauseProfile', config: { policies: [] } },
|
|
95
|
+
{ method: 'POST', path: '/media-sync/profiles/:id/resume', handler: 'syncMedia.resumeProfile', config: { policies: [] } },
|
|
96
|
+
{ method: 'POST', path: '/media-sync/profiles/:id/cancel', handler: 'syncMedia.cancelProfile', config: { policies: [] } },
|
|
94
97
|
{ method: 'POST', path: '/media-sync/run-active', handler: 'syncMedia.runActiveProfiles', config: { policies: [] } },
|
|
95
98
|
{ method: 'GET', path: '/media-sync/morph-links', handler: 'syncMedia.getMorphLinks', config: { policies: [] } },
|
|
96
99
|
{ method: 'POST', path: '/media-sync/morph-links/apply', handler: 'syncMedia.applyMorphLinks', config: { policies: [] } },
|
|
@@ -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,
|