strapi-content-sync-pro 1.0.2 → 1.0.4
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/README.md +67 -18
- package/admin/src/components/BulkTransferTab.jsx +880 -0
- package/admin/src/components/ConfigTab.jsx +25 -4
- package/admin/src/components/HelpTab.jsx +201 -15
- package/admin/src/components/MediaTab.jsx +7 -0
- package/admin/src/components/StatsTab.jsx +470 -0
- package/admin/src/components/SyncProfilesTab.jsx +63 -5
- package/admin/src/components/SyncTab.jsx +53 -7
- package/admin/src/pages/App/index.jsx +15 -1
- package/docs/clipchamp-screen-recording-script.md +0 -0
- package/docs/production-readiness-status.md +34 -0
- package/docs/production-readiness-test-matrix.md +151 -0
- package/docs/test-environments-setup-legacy.txt +60 -0
- package/package.json +13 -4
- package/server/src/content-types/index.js +2 -0
- package/server/src/content-types/sync-run-report/schema.json +26 -0
- package/server/src/controllers/bulk-transfer.js +141 -0
- package/server/src/controllers/config.js +48 -5
- package/server/src/controllers/index.js +4 -0
- package/server/src/controllers/sync-log.js +6 -0
- package/server/src/controllers/sync-media.js +19 -0
- package/server/src/controllers/sync-stats.js +51 -0
- package/server/src/controllers/sync.js +9 -3
- package/server/src/routes/index.js +28 -0
- package/server/src/services/bulk-transfer.js +837 -0
- package/server/src/services/config.js +18 -2
- package/server/src/services/index.js +4 -0
- package/server/src/services/sync-execution.js +102 -5
- package/server/src/services/sync-log.js +36 -0
- package/server/src/services/sync-media.js +224 -1
- package/server/src/services/sync-profiles.js +92 -4
- package/server/src/services/sync-stats.js +353 -0
- package/server/src/services/sync.js +323 -101
- package/server/src/utils/applier.js +120 -13
- package/server/src/utils/comparator.js +22 -6
- package/server/src/utils/fetcher.js +11 -2
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const STORE_KEY = 'remote-server-config';
|
|
4
4
|
|
|
5
5
|
const SENSITIVE_FIELDS = ['apiToken', 'sharedSecret'];
|
|
6
|
+
const VALID_SYNC_MODES = ['paired', 'single_side'];
|
|
6
7
|
|
|
7
8
|
module.exports = ({ strapi }) => {
|
|
8
9
|
function getStore() {
|
|
@@ -21,11 +22,16 @@ module.exports = ({ strapi }) => {
|
|
|
21
22
|
return null;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
const normalized = {
|
|
26
|
+
syncMode: 'paired',
|
|
27
|
+
...data,
|
|
28
|
+
};
|
|
29
|
+
|
|
24
30
|
if (!safe) {
|
|
25
|
-
return
|
|
31
|
+
return normalized;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
const sanitized = { ...
|
|
34
|
+
const sanitized = { ...normalized };
|
|
29
35
|
for (const field of SENSITIVE_FIELDS) {
|
|
30
36
|
if (sanitized[field]) {
|
|
31
37
|
sanitized[field] = '••••••••';
|
|
@@ -59,6 +65,16 @@ module.exports = ({ strapi }) => {
|
|
|
59
65
|
if (config.sharedSecret !== undefined) {
|
|
60
66
|
merged.sharedSecret = config.sharedSecret;
|
|
61
67
|
}
|
|
68
|
+
if (config.syncMode !== undefined) {
|
|
69
|
+
if (!VALID_SYNC_MODES.includes(config.syncMode)) {
|
|
70
|
+
throw new Error(`syncMode must be one of: ${VALID_SYNC_MODES.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
merged.syncMode = config.syncMode;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!merged.syncMode) {
|
|
76
|
+
merged.syncMode = 'paired';
|
|
77
|
+
}
|
|
62
78
|
|
|
63
79
|
await store.set({ key: STORE_KEY, value: merged });
|
|
64
80
|
|
|
@@ -12,6 +12,8 @@ const dependencyResolver = require('./dependency-resolver');
|
|
|
12
12
|
const syncEnforcement = require('./sync-enforcement');
|
|
13
13
|
const syncMedia = require('./sync-media');
|
|
14
14
|
const alerts = require('./alerts');
|
|
15
|
+
const syncStats = require('./sync-stats');
|
|
16
|
+
const bulkTransfer = require('./bulk-transfer');
|
|
15
17
|
|
|
16
18
|
module.exports = {
|
|
17
19
|
ping,
|
|
@@ -26,5 +28,7 @@ module.exports = {
|
|
|
26
28
|
syncEnforcement,
|
|
27
29
|
syncMedia,
|
|
28
30
|
alerts,
|
|
31
|
+
syncStats,
|
|
32
|
+
bulkTransfer,
|
|
29
33
|
};
|
|
30
34
|
|
|
@@ -67,6 +67,8 @@ module.exports = ({ strapi }) => {
|
|
|
67
67
|
retryOnFailure: true,
|
|
68
68
|
retryAttempts: 3,
|
|
69
69
|
retryDelayMs: 5000,
|
|
70
|
+
maxLogEntries: 2000,
|
|
71
|
+
maxReportEntries: 200,
|
|
70
72
|
// Pagination for content-type sync fetches (local + remote). Larger pages
|
|
71
73
|
// are faster but use more memory per chunk. 100 is a safe default.
|
|
72
74
|
syncPageSize: 100,
|
|
@@ -75,6 +77,12 @@ module.exports = ({ strapi }) => {
|
|
|
75
77
|
const VALID_EXECUTION_MODES = ['on_demand', 'scheduled', 'live'];
|
|
76
78
|
const VALID_SCHEDULE_TYPES = ['interval', 'timeout', 'cron', 'external'];
|
|
77
79
|
|
|
80
|
+
async function getSyncMode() {
|
|
81
|
+
const configService = plugin().service('config');
|
|
82
|
+
const config = await configService.getConfig({ safe: false });
|
|
83
|
+
return config?.syncMode || 'paired';
|
|
84
|
+
}
|
|
85
|
+
|
|
78
86
|
// Basic cron validator: 5 or 6 space-separated fields
|
|
79
87
|
function isValidCronExpression(expr) {
|
|
80
88
|
if (typeof expr !== 'string') return false;
|
|
@@ -156,6 +164,11 @@ module.exports = ({ strapi }) => {
|
|
|
156
164
|
throw new Error(`Invalid schedule type "${executionSettings.scheduleType}". Must be one of: ${VALID_SCHEDULE_TYPES.join(', ')}`);
|
|
157
165
|
}
|
|
158
166
|
|
|
167
|
+
const syncMode = await getSyncMode();
|
|
168
|
+
if (syncMode === 'single_side' && executionSettings.executionMode === 'live') {
|
|
169
|
+
throw new Error('Live execution mode is not available in single-side mode. Use on_demand or scheduled.');
|
|
170
|
+
}
|
|
171
|
+
|
|
159
172
|
// Validate schedule interval (used by interval & timeout types)
|
|
160
173
|
if (executionSettings.scheduleInterval !== undefined) {
|
|
161
174
|
if (executionSettings.scheduleInterval < 1 || executionSettings.scheduleInterval > 1440) {
|
|
@@ -185,6 +198,11 @@ module.exports = ({ strapi }) => {
|
|
|
185
198
|
...executionSettings,
|
|
186
199
|
updatedAt: new Date().toISOString(),
|
|
187
200
|
};
|
|
201
|
+
|
|
202
|
+
if (syncMode === 'single_side' && merged.executionMode === 'live') {
|
|
203
|
+
merged.executionMode = 'on_demand';
|
|
204
|
+
merged.enabled = false;
|
|
205
|
+
}
|
|
188
206
|
settings.profiles[profileId] = merged;
|
|
189
207
|
|
|
190
208
|
// Calculate an advisory nextExecutionAt for scheduled mode
|
|
@@ -223,11 +241,20 @@ module.exports = ({ strapi }) => {
|
|
|
223
241
|
const store = getStore();
|
|
224
242
|
const settings = await this.getExecutionSettings();
|
|
225
243
|
|
|
226
|
-
|
|
244
|
+
const next = {
|
|
227
245
|
...settings.globalSettings,
|
|
228
246
|
...globalSettings,
|
|
229
247
|
};
|
|
230
248
|
|
|
249
|
+
if (next.maxLogEntries !== undefined && Number(next.maxLogEntries) < 100) {
|
|
250
|
+
throw new Error('maxLogEntries must be at least 100');
|
|
251
|
+
}
|
|
252
|
+
if (next.maxReportEntries !== undefined && Number(next.maxReportEntries) < 10) {
|
|
253
|
+
throw new Error('maxReportEntries must be at least 10');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
settings.globalSettings = next;
|
|
257
|
+
|
|
231
258
|
await store.set({ key: STORE_KEY, value: settings });
|
|
232
259
|
return settings.globalSettings;
|
|
233
260
|
},
|
|
@@ -238,8 +265,10 @@ module.exports = ({ strapi }) => {
|
|
|
238
265
|
async executeProfile(profileId, options = {}) {
|
|
239
266
|
const syncService = plugin().service('sync');
|
|
240
267
|
const profilesService = plugin().service('syncProfiles');
|
|
268
|
+
const dependencyResolver = plugin().service('dependencyResolver');
|
|
241
269
|
const alertsService = plugin().service('alerts');
|
|
242
270
|
const logService = plugin().service('syncLog');
|
|
271
|
+
const syncStatsService = plugin().service('syncStats');
|
|
243
272
|
|
|
244
273
|
const profile = await profilesService.getProfile(profileId);
|
|
245
274
|
if (!profile) {
|
|
@@ -251,6 +280,11 @@ module.exports = ({ strapi }) => {
|
|
|
251
280
|
const dependencyDepth = options.dependencyDepth ?? executionSettings.dependencyDepth ?? 1;
|
|
252
281
|
|
|
253
282
|
const startTime = new Date();
|
|
283
|
+
const reportHandle = await syncStatsService.createRunReport({
|
|
284
|
+
runType: 'profile_execution',
|
|
285
|
+
trigger: executionSettings.executionMode || 'on_demand',
|
|
286
|
+
contentTypes: [profile.contentType],
|
|
287
|
+
});
|
|
254
288
|
|
|
255
289
|
try {
|
|
256
290
|
// Log execution start
|
|
@@ -263,6 +297,41 @@ module.exports = ({ strapi }) => {
|
|
|
263
297
|
details: { profileId, syncDependencies, dependencyDepth },
|
|
264
298
|
});
|
|
265
299
|
|
|
300
|
+
const dependencyResults = [];
|
|
301
|
+
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);
|
|
310
|
+
if (!depEnabled) {
|
|
311
|
+
dependencyResults.push({
|
|
312
|
+
uid: dependencyUid,
|
|
313
|
+
profile: null,
|
|
314
|
+
skipped: true,
|
|
315
|
+
reason: 'dependency content-type not enabled for sync',
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const dependencyProfile = await profilesService.getActiveProfileForContentType(dependencyUid);
|
|
321
|
+
const dependencyResult = await syncService.syncContentType(dependencyUid, {
|
|
322
|
+
profile: dependencyProfile,
|
|
323
|
+
syncDependencies: false,
|
|
324
|
+
dependencyDepth: 1,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
dependencyResults.push({
|
|
328
|
+
uid: dependencyUid,
|
|
329
|
+
profile: dependencyProfile ? { id: dependencyProfile.id, name: dependencyProfile.name } : null,
|
|
330
|
+
result: dependencyResult,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
266
335
|
// Execute sync
|
|
267
336
|
const result = await syncService.syncContentType(profile.contentType, {
|
|
268
337
|
profile,
|
|
@@ -270,19 +339,39 @@ module.exports = ({ strapi }) => {
|
|
|
270
339
|
dependencyDepth,
|
|
271
340
|
});
|
|
272
341
|
|
|
342
|
+
const fullResult = {
|
|
343
|
+
...result,
|
|
344
|
+
dependencyResults,
|
|
345
|
+
};
|
|
346
|
+
|
|
273
347
|
// Update last execution time
|
|
274
348
|
await this.updateLastExecution(profileId);
|
|
275
349
|
|
|
350
|
+
await syncStatsService.completeRunReport(reportHandle.reportId, {
|
|
351
|
+
status: 'success',
|
|
352
|
+
summary: fullResult,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const globalSettings = await this.getGlobalSettings();
|
|
356
|
+
await logService.applyRetention({ maxLogs: globalSettings.maxLogEntries });
|
|
357
|
+
await syncStatsService.applyRetention({ maxReports: globalSettings.maxReportEntries });
|
|
358
|
+
|
|
276
359
|
// Send success alert
|
|
277
360
|
await alertsService.sendAlert('sync_success', {
|
|
278
361
|
profile: profile.name,
|
|
279
362
|
contentType: profile.contentType,
|
|
280
|
-
result,
|
|
363
|
+
result: fullResult,
|
|
281
364
|
duration: Date.now() - startTime.getTime(),
|
|
282
365
|
});
|
|
283
366
|
|
|
284
|
-
return
|
|
367
|
+
return fullResult;
|
|
285
368
|
} catch (error) {
|
|
369
|
+
await syncStatsService.completeRunReport(reportHandle.reportId, {
|
|
370
|
+
status: 'error',
|
|
371
|
+
summary: null,
|
|
372
|
+
error: error.message,
|
|
373
|
+
});
|
|
374
|
+
|
|
286
375
|
// Send failure alert
|
|
287
376
|
await alertsService.sendAlert('sync_failure', {
|
|
288
377
|
profile: profile.name,
|
|
@@ -515,23 +604,31 @@ module.exports = ({ strapi }) => {
|
|
|
515
604
|
const profilesService = plugin().service('syncProfiles');
|
|
516
605
|
const profiles = await profilesService.getProfiles();
|
|
517
606
|
|
|
607
|
+
const syncMode = await getSyncMode();
|
|
518
608
|
const status = [];
|
|
519
609
|
for (const profile of profiles) {
|
|
520
610
|
const execSettings = settings.profiles[profile.id] || {};
|
|
521
611
|
const handle = schedulerHandles[profile.id];
|
|
612
|
+
const executionMode = syncMode === 'single_side' && execSettings.executionMode === 'live'
|
|
613
|
+
? 'on_demand'
|
|
614
|
+
: (execSettings.executionMode || 'on_demand');
|
|
615
|
+
|
|
522
616
|
status.push({
|
|
523
617
|
profileId: profile.id,
|
|
524
618
|
profileName: profile.name,
|
|
525
619
|
contentType: profile.contentType,
|
|
526
|
-
executionMode
|
|
620
|
+
executionMode,
|
|
527
621
|
scheduleType: execSettings.scheduleType || null,
|
|
528
622
|
scheduleInterval: execSettings.scheduleInterval || null,
|
|
529
623
|
cronExpression: execSettings.cronExpression || null,
|
|
530
|
-
enabled: execSettings.
|
|
624
|
+
enabled: syncMode === 'single_side' && execSettings.executionMode === 'live'
|
|
625
|
+
? false
|
|
626
|
+
: (execSettings.enabled || false),
|
|
531
627
|
lastExecutedAt: execSettings.lastExecutedAt || null,
|
|
532
628
|
nextExecutionAt: execSettings.nextExecutionAt || null,
|
|
533
629
|
isSchedulerRunning: !!handle && !handle.external,
|
|
534
630
|
isExternal: !!(handle && handle.external),
|
|
631
|
+
syncMode,
|
|
535
632
|
});
|
|
536
633
|
}
|
|
537
634
|
|
|
@@ -53,4 +53,40 @@ module.exports = ({ strapi }) => ({
|
|
|
53
53
|
},
|
|
54
54
|
};
|
|
55
55
|
},
|
|
56
|
+
|
|
57
|
+
async clearLogs() {
|
|
58
|
+
const existing = await strapi.documents(CONTENT_TYPE_UID).findMany({
|
|
59
|
+
fields: ['documentId'],
|
|
60
|
+
limit: 10000,
|
|
61
|
+
sort: { createdAt: 'desc' },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
for (const entry of existing) {
|
|
65
|
+
if (!entry?.documentId) continue;
|
|
66
|
+
await strapi.documents(CONTENT_TYPE_UID).delete({ documentId: entry.documentId });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { deleted: existing.length };
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async applyRetention({ maxLogs = 2000 } = {}) {
|
|
73
|
+
const safeMax = Math.max(100, Number(maxLogs) || 2000);
|
|
74
|
+
const count = await strapi.documents(CONTENT_TYPE_UID).count();
|
|
75
|
+
if (count <= safeMax) return { pruned: 0, remaining: count };
|
|
76
|
+
|
|
77
|
+
const excess = count - safeMax;
|
|
78
|
+
const toDelete = await strapi.documents(CONTENT_TYPE_UID).findMany({
|
|
79
|
+
fields: ['documentId'],
|
|
80
|
+
sort: { createdAt: 'asc' },
|
|
81
|
+
limit: excess,
|
|
82
|
+
start: 0,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
for (const entry of toDelete) {
|
|
86
|
+
if (!entry?.documentId) continue;
|
|
87
|
+
await strapi.documents(CONTENT_TYPE_UID).delete({ documentId: entry.documentId });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { pruned: toDelete.length, remaining: count - toDelete.length };
|
|
91
|
+
},
|
|
56
92
|
});
|
|
@@ -155,6 +155,31 @@ module.exports = ({ strapi }) => {
|
|
|
155
155
|
return strapi.plugin(PLUGIN_NAME);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Morph join-table resolution
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Strapi's upload plugin stores polymorphic file↔entity links in a morph
|
|
162
|
+
// join table whose name differs between Strapi versions (e.g.
|
|
163
|
+
// `files_related_morphs` on some v4 builds vs `files_related_mph` on
|
|
164
|
+
// Strapi v5). Resolve it from ORM metadata so the plugin works everywhere.
|
|
165
|
+
let _morphTableCache = null;
|
|
166
|
+
function resolveMorphTable() {
|
|
167
|
+
if (_morphTableCache) return _morphTableCache;
|
|
168
|
+
const candidates = [];
|
|
169
|
+
try {
|
|
170
|
+
const meta = strapi.db?.metadata?.get?.('plugin::upload.file');
|
|
171
|
+
const attr = meta?.attributes?.related;
|
|
172
|
+
if (attr?.joinTable?.name) candidates.push(attr.joinTable.name);
|
|
173
|
+
if (attr?.pivotTable) candidates.push(attr.pivotTable);
|
|
174
|
+
} catch {
|
|
175
|
+
// ignore — fall back to known names below
|
|
176
|
+
}
|
|
177
|
+
// Known historical defaults, most-recent first.
|
|
178
|
+
candidates.push('files_related_mph', 'files_related_morphs');
|
|
179
|
+
_morphTableCache = candidates.find((n) => !!n) || 'files_related_mph';
|
|
180
|
+
return _morphTableCache;
|
|
181
|
+
}
|
|
182
|
+
|
|
158
183
|
// ---------------------------------------------------------------------------
|
|
159
184
|
// Profile CRUD
|
|
160
185
|
// ---------------------------------------------------------------------------
|
|
@@ -408,6 +433,170 @@ module.exports = ({ strapi }) => {
|
|
|
408
433
|
return 'needs_bytes';
|
|
409
434
|
}
|
|
410
435
|
|
|
436
|
+
async function exportMorphLinks() {
|
|
437
|
+
const morphTable = resolveMorphTable();
|
|
438
|
+
const rows = await strapi.db.connection(morphTable).select('*');
|
|
439
|
+
const fileCache = new Map();
|
|
440
|
+
const relatedDocCache = new Map();
|
|
441
|
+
const out = [];
|
|
442
|
+
|
|
443
|
+
for (const row of rows) {
|
|
444
|
+
const fileId = Number(row.file_id);
|
|
445
|
+
if (!fileCache.has(fileId)) {
|
|
446
|
+
const file = await strapi.db.query('plugin::upload.file').findOne({
|
|
447
|
+
where: { id: fileId },
|
|
448
|
+
select: ['id', 'documentId'],
|
|
449
|
+
});
|
|
450
|
+
fileCache.set(fileId, file || null);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const file = fileCache.get(fileId);
|
|
454
|
+
if (!file?.documentId) continue;
|
|
455
|
+
|
|
456
|
+
const relatedType = row.related_type;
|
|
457
|
+
const relatedId = Number(row.related_id);
|
|
458
|
+
const relatedKey = `${relatedType}:${relatedId}`;
|
|
459
|
+
|
|
460
|
+
if (!relatedDocCache.has(relatedKey)) {
|
|
461
|
+
try {
|
|
462
|
+
const entity = await strapi.db.query(relatedType).findOne({
|
|
463
|
+
where: { id: relatedId },
|
|
464
|
+
select: ['id', 'documentId'],
|
|
465
|
+
});
|
|
466
|
+
relatedDocCache.set(relatedKey, entity?.documentId || null);
|
|
467
|
+
} catch {
|
|
468
|
+
relatedDocCache.set(relatedKey, null);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const relatedDocumentId = relatedDocCache.get(relatedKey);
|
|
473
|
+
if (!relatedDocumentId) continue;
|
|
474
|
+
|
|
475
|
+
out.push({
|
|
476
|
+
fileDocumentId: file.documentId,
|
|
477
|
+
relatedType,
|
|
478
|
+
relatedDocumentId,
|
|
479
|
+
field: row.field || null,
|
|
480
|
+
order: row.order || 1,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function applyMorphLinks(links = []) {
|
|
488
|
+
const applied = [];
|
|
489
|
+
const skipped = [];
|
|
490
|
+
const errors = [];
|
|
491
|
+
|
|
492
|
+
for (const link of links) {
|
|
493
|
+
try {
|
|
494
|
+
if (!link?.fileDocumentId || !link?.relatedType || !link?.relatedDocumentId) {
|
|
495
|
+
skipped.push({ link, reason: 'missing required documentId fields' });
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const file = await strapi.db.query('plugin::upload.file').findOne({
|
|
500
|
+
where: { documentId: link.fileDocumentId },
|
|
501
|
+
select: ['id', 'documentId'],
|
|
502
|
+
});
|
|
503
|
+
if (!file?.id) {
|
|
504
|
+
skipped.push({ link, reason: 'file documentId not found locally' });
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let related = null;
|
|
509
|
+
try {
|
|
510
|
+
related = await strapi.db.query(link.relatedType).findOne({
|
|
511
|
+
where: { documentId: link.relatedDocumentId },
|
|
512
|
+
select: ['id', 'documentId'],
|
|
513
|
+
});
|
|
514
|
+
} catch {
|
|
515
|
+
skipped.push({ link, reason: 'related type not queryable locally' });
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (!related?.id) {
|
|
520
|
+
skipped.push({ link, reason: 'related documentId not found locally' });
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const morphTable = resolveMorphTable();
|
|
525
|
+
let existsQ = strapi.db.connection(morphTable)
|
|
526
|
+
.where('file_id', file.id)
|
|
527
|
+
.andWhere('related_id', related.id)
|
|
528
|
+
.andWhere('related_type', link.relatedType);
|
|
529
|
+
|
|
530
|
+
if (link.field) existsQ = existsQ.andWhere('field', link.field);
|
|
531
|
+
else existsQ = existsQ.whereNull('field');
|
|
532
|
+
|
|
533
|
+
const existing = await existsQ.first();
|
|
534
|
+
if (existing) {
|
|
535
|
+
skipped.push({ link, reason: 'morph link already exists' });
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
await strapi.db.connection(morphTable).insert({
|
|
540
|
+
file_id: file.id,
|
|
541
|
+
related_id: related.id,
|
|
542
|
+
related_type: link.relatedType,
|
|
543
|
+
field: link.field || null,
|
|
544
|
+
order: link.order || 1,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
applied.push(link);
|
|
548
|
+
} catch (err) {
|
|
549
|
+
errors.push({ link, error: err.message });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
total: links.length,
|
|
555
|
+
applied: applied.length,
|
|
556
|
+
skipped: skipped.length,
|
|
557
|
+
errors,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function fetchRemoteMorphLinks(remoteConfig) {
|
|
562
|
+
const url = new URL('/api/strapi-content-sync-pro/media-sync/morph-links', remoteConfig.baseUrl);
|
|
563
|
+
const res = await fetch(url.toString(), {
|
|
564
|
+
method: 'GET',
|
|
565
|
+
headers: {
|
|
566
|
+
Authorization: `Bearer ${remoteConfig.apiToken}`,
|
|
567
|
+
'Content-Type': 'application/json',
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
if (!res.ok) {
|
|
572
|
+
const body = await safeReadBody(res);
|
|
573
|
+
throw new Error(`Remote morph-links fetch failed (${res.status}): ${body}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const json = await res.json();
|
|
577
|
+
return json?.data || [];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function applyRemoteMorphLinks(remoteConfig, links = []) {
|
|
581
|
+
const url = new URL('/api/strapi-content-sync-pro/media-sync/morph-links/apply', remoteConfig.baseUrl);
|
|
582
|
+
const res = await fetch(url.toString(), {
|
|
583
|
+
method: 'POST',
|
|
584
|
+
headers: {
|
|
585
|
+
Authorization: `Bearer ${remoteConfig.apiToken}`,
|
|
586
|
+
'Content-Type': 'application/json',
|
|
587
|
+
},
|
|
588
|
+
body: JSON.stringify({ links }),
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
if (!res.ok) {
|
|
592
|
+
const body = await safeReadBody(res);
|
|
593
|
+
throw new Error(`Remote morph-links apply failed (${res.status}): ${body}`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const json = await res.json();
|
|
597
|
+
return json?.data || { total: links.length, applied: 0, skipped: 0, errors: [] };
|
|
598
|
+
}
|
|
599
|
+
|
|
411
600
|
// ---------------------------------------------------------------------------
|
|
412
601
|
// URL strategy
|
|
413
602
|
// ---------------------------------------------------------------------------
|
|
@@ -594,7 +783,7 @@ module.exports = ({ strapi }) => {
|
|
|
594
783
|
const remoteConfig = await configService.getConfig({ safe: false });
|
|
595
784
|
if (!remoteConfig?.baseUrl) throw new Error('Remote server not configured');
|
|
596
785
|
|
|
597
|
-
const totals = { pushed: 0, pulled: 0, skipped: 0, dbRowsUpdated: 0, errors: [] };
|
|
786
|
+
const totals = { pushed: 0, pulled: 0, skipped: 0, dbRowsUpdated: 0, morphLinksApplied: 0, morphLinksSkipped: 0, errors: [] };
|
|
598
787
|
const started = Date.now();
|
|
599
788
|
|
|
600
789
|
const localIndex = new Map();
|
|
@@ -631,6 +820,22 @@ module.exports = ({ strapi }) => {
|
|
|
631
820
|
}
|
|
632
821
|
}
|
|
633
822
|
|
|
823
|
+
if (profile.syncDbRows) {
|
|
824
|
+
try {
|
|
825
|
+
if (settings.direction === 'pull' || settings.direction === 'both') {
|
|
826
|
+
const remoteLinks = await fetchRemoteMorphLinks(remoteConfig);
|
|
827
|
+
const applyResult = await applyMorphLinks(remoteLinks);
|
|
828
|
+
totals.morphLinksApplied += applyResult.applied || 0;
|
|
829
|
+
totals.morphLinksSkipped += applyResult.skipped || 0;
|
|
830
|
+
if (Array.isArray(applyResult.errors) && applyResult.errors.length > 0) {
|
|
831
|
+
totals.errors.push(...applyResult.errors.map((e) => ({ name: 'morph_pull', error: e.error || 'morph apply error' })));
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
} catch (err) {
|
|
835
|
+
totals.errors.push({ name: 'morph_pull', error: err.message });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
634
839
|
// PUSH: local -> remote
|
|
635
840
|
if (settings.direction === 'push' || settings.direction === 'both') {
|
|
636
841
|
const remoteIndex = new Map();
|
|
@@ -666,6 +871,20 @@ module.exports = ({ strapi }) => {
|
|
|
666
871
|
}
|
|
667
872
|
}
|
|
668
873
|
|
|
874
|
+
if (profile.syncDbRows && (settings.direction === 'push' || settings.direction === 'both')) {
|
|
875
|
+
try {
|
|
876
|
+
const localLinks = await exportMorphLinks();
|
|
877
|
+
const applyRemoteResult = await applyRemoteMorphLinks(remoteConfig, localLinks);
|
|
878
|
+
totals.morphLinksApplied += applyRemoteResult.applied || 0;
|
|
879
|
+
totals.morphLinksSkipped += applyRemoteResult.skipped || 0;
|
|
880
|
+
if (Array.isArray(applyRemoteResult.errors) && applyRemoteResult.errors.length > 0) {
|
|
881
|
+
totals.errors.push(...applyRemoteResult.errors.map((e) => ({ name: 'morph_push', error: e.error || 'remote morph apply error' })));
|
|
882
|
+
}
|
|
883
|
+
} catch (err) {
|
|
884
|
+
totals.errors.push({ name: 'morph_push', error: err.message });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
669
888
|
const summary = {
|
|
670
889
|
strategy: 'url',
|
|
671
890
|
profileId: profile.id,
|
|
@@ -900,6 +1119,10 @@ module.exports = ({ strapi }) => {
|
|
|
900
1119
|
runProfile,
|
|
901
1120
|
runActiveProfiles,
|
|
902
1121
|
|
|
1122
|
+
// Morph link APIs (documentId-based)
|
|
1123
|
+
exportMorphLinks,
|
|
1124
|
+
applyMorphLinks,
|
|
1125
|
+
|
|
903
1126
|
// Schedulers
|
|
904
1127
|
initializeSchedulers,
|
|
905
1128
|
stopAllSchedulers,
|