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
@@ -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.4",
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 (!baseUrl || !email || !password) {
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 errorMessage = errorBody?.error?.message || `Login failed with status ${loginResponse.status}`;
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
- 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,