strapi-content-sync-pro 1.0.0
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 +21 -0
- package/README.md +206 -0
- package/admin/src/components/ConfigTab.jsx +1038 -0
- package/admin/src/components/ContentTypesTab.jsx +160 -0
- package/admin/src/components/HelpTab.jsx +945 -0
- package/admin/src/components/LogsTab.jsx +136 -0
- package/admin/src/components/MediaTab.jsx +557 -0
- package/admin/src/components/SyncProfilesTab.jsx +715 -0
- package/admin/src/components/SyncTab.jsx +988 -0
- package/admin/src/index.js +31 -0
- package/admin/src/pages/App/index.jsx +129 -0
- package/admin/src/pluginId.js +3 -0
- package/package.json +84 -0
- package/server/src/bootstrap.js +151 -0
- package/server/src/config/index.js +5 -0
- package/server/src/content-types/index.js +7 -0
- package/server/src/content-types/sync-log/schema.json +24 -0
- package/server/src/controllers/alerts.js +59 -0
- package/server/src/controllers/config.js +292 -0
- package/server/src/controllers/content-type-discovery.js +9 -0
- package/server/src/controllers/dependencies.js +109 -0
- package/server/src/controllers/index.js +29 -0
- package/server/src/controllers/ping.js +7 -0
- package/server/src/controllers/sync-config.js +26 -0
- package/server/src/controllers/sync-enforcement.js +323 -0
- package/server/src/controllers/sync-execution.js +134 -0
- package/server/src/controllers/sync-log.js +18 -0
- package/server/src/controllers/sync-media.js +158 -0
- package/server/src/controllers/sync-profiles.js +182 -0
- package/server/src/controllers/sync.js +31 -0
- package/server/src/destroy.js +7 -0
- package/server/src/index.js +21 -0
- package/server/src/middlewares/verify-signature.js +32 -0
- package/server/src/register.js +7 -0
- package/server/src/routes/index.js +111 -0
- package/server/src/services/alerts.js +437 -0
- package/server/src/services/config.js +68 -0
- package/server/src/services/content-type-discovery.js +41 -0
- package/server/src/services/dependency-resolver.js +284 -0
- package/server/src/services/index.js +30 -0
- package/server/src/services/ping.js +7 -0
- package/server/src/services/sync-config.js +45 -0
- package/server/src/services/sync-enforcement.js +362 -0
- package/server/src/services/sync-execution.js +541 -0
- package/server/src/services/sync-log.js +56 -0
- package/server/src/services/sync-media.js +963 -0
- package/server/src/services/sync-profiles.js +380 -0
- package/server/src/services/sync.js +248 -0
- package/server/src/utils/applier.js +89 -0
- package/server/src/utils/comparator.js +83 -0
- package/server/src/utils/fetcher.js +142 -0
- package/server/src/utils/hmac.js +37 -0
- package/server/src/utils/pagination.js +51 -0
- package/server/src/utils/sync-guard.js +29 -0
- package/server/src/utils/sync-id.js +16 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const STORE_KEY = 'sync-execution-settings';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sync Execution Service
|
|
7
|
+
*
|
|
8
|
+
* Manages WHEN and HOW sync profiles are executed:
|
|
9
|
+
* - On-demand (manual trigger)
|
|
10
|
+
* - Scheduled (interval / timeout / cron / external)
|
|
11
|
+
* - Live (real-time via lifecycle hooks)
|
|
12
|
+
*
|
|
13
|
+
* Execution Settings Structure:
|
|
14
|
+
* {
|
|
15
|
+
* profiles: {
|
|
16
|
+
* [profileId]: {
|
|
17
|
+
* executionMode: 'on_demand' | 'scheduled' | 'live',
|
|
18
|
+
* scheduleType: 'interval' | 'timeout' | 'cron' | 'external',
|
|
19
|
+
* scheduleInterval: number (minutes, used by interval/timeout),
|
|
20
|
+
* cronExpression: string (used by cron),
|
|
21
|
+
* lastExecutedAt: ISO string,
|
|
22
|
+
* nextExecutionAt: ISO string,
|
|
23
|
+
* enabled: boolean,
|
|
24
|
+
* syncDependencies: boolean,
|
|
25
|
+
* dependencyDepth: number (1-5)
|
|
26
|
+
* }
|
|
27
|
+
* },
|
|
28
|
+
* globalSettings: {
|
|
29
|
+
* maxConcurrentSyncs: number,
|
|
30
|
+
* retryOnFailure: boolean,
|
|
31
|
+
* retryAttempts: number,
|
|
32
|
+
* retryDelayMs: number
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* Scheduler Types
|
|
37
|
+
* ---------------
|
|
38
|
+
* - interval : Node setInterval. Lightweight, fires every N minutes from when it
|
|
39
|
+
* was started. Drifts slightly. Misses runs if the process is
|
|
40
|
+
* blocked. Best for small / frequent syncs.
|
|
41
|
+
* - timeout : Chained setTimeout. Re-computes its next run only after the
|
|
42
|
+
* previous run completes, so overlapping runs are impossible. Best
|
|
43
|
+
* when individual syncs can take a long time.
|
|
44
|
+
* - cron : Uses Strapi's built-in cron (node-schedule). Supports full cron
|
|
45
|
+
* expressions (e.g. "0 *\/2 * * *"). Persists the next-run wall-
|
|
46
|
+
* clock time and survives short pauses reliably. Recommended for
|
|
47
|
+
* larger datasets and production systems.
|
|
48
|
+
* - external : The plugin registers NO in-process schedule. Instead, an
|
|
49
|
+
* external scheduler (systemd timer, Windows Task Scheduler,
|
|
50
|
+
* Kubernetes CronJob, GitHub Actions, cloud scheduler, ...) must
|
|
51
|
+
* POST /api/strapi-content-sync-pro/sync-execution/execute/:id
|
|
52
|
+
* to drive the run. Recommended for large datasets, multi-node
|
|
53
|
+
* deployments, and HA setups where you can't rely on a single
|
|
54
|
+
* Node process staying up.
|
|
55
|
+
*/
|
|
56
|
+
module.exports = ({ strapi }) => {
|
|
57
|
+
function getStore() {
|
|
58
|
+
return strapi.store({ type: 'plugin', name: 'strapi-content-sync-pro' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function plugin() {
|
|
62
|
+
return strapi.plugin('strapi-content-sync-pro');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_GLOBAL_SETTINGS = {
|
|
66
|
+
maxConcurrentSyncs: 3,
|
|
67
|
+
retryOnFailure: true,
|
|
68
|
+
retryAttempts: 3,
|
|
69
|
+
retryDelayMs: 5000,
|
|
70
|
+
// Pagination for content-type sync fetches (local + remote). Larger pages
|
|
71
|
+
// are faster but use more memory per chunk. 100 is a safe default.
|
|
72
|
+
syncPageSize: 100,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const VALID_EXECUTION_MODES = ['on_demand', 'scheduled', 'live'];
|
|
76
|
+
const VALID_SCHEDULE_TYPES = ['interval', 'timeout', 'cron', 'external'];
|
|
77
|
+
|
|
78
|
+
// Basic cron validator: 5 or 6 space-separated fields
|
|
79
|
+
function isValidCronExpression(expr) {
|
|
80
|
+
if (typeof expr !== 'string') return false;
|
|
81
|
+
const parts = expr.trim().split(/\s+/);
|
|
82
|
+
if (parts.length < 5 || parts.length > 6) return false;
|
|
83
|
+
// very loose validator — detailed validation is done by node-schedule
|
|
84
|
+
return parts.every((p) => /^[\d\*\/,\-\?LW#A-Za-z]+$/.test(p));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// In-memory scheduler state per profile
|
|
88
|
+
// { intervalId?, timeoutId?, cronKey? }
|
|
89
|
+
const schedulerHandles = {};
|
|
90
|
+
let liveHooksRegistered = false;
|
|
91
|
+
|
|
92
|
+
// -- scheduler helpers -----------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function clearHandles(profileId) {
|
|
95
|
+
const h = schedulerHandles[profileId];
|
|
96
|
+
if (!h) return;
|
|
97
|
+
if (h.intervalId) clearInterval(h.intervalId);
|
|
98
|
+
if (h.timeoutId) clearTimeout(h.timeoutId);
|
|
99
|
+
if (h.cronKey && strapi.cron && typeof strapi.cron.remove === 'function') {
|
|
100
|
+
try { strapi.cron.remove(h.cronKey); } catch (_) { /* ignore */ }
|
|
101
|
+
}
|
|
102
|
+
delete schedulerHandles[profileId];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function runSafely(profileId, runner) {
|
|
106
|
+
try {
|
|
107
|
+
await runner();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
strapi.log.error(`[data-sync] Scheduled run failed for profile ${profileId}: ${error.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
/**
|
|
116
|
+
* Get all execution settings
|
|
117
|
+
*/
|
|
118
|
+
async getExecutionSettings() {
|
|
119
|
+
const store = getStore();
|
|
120
|
+
const data = await store.get({ key: STORE_KEY });
|
|
121
|
+
return data || { profiles: {}, globalSettings: DEFAULT_GLOBAL_SETTINGS };
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get execution settings for a specific profile
|
|
126
|
+
*/
|
|
127
|
+
async getProfileExecutionSettings(profileId) {
|
|
128
|
+
const settings = await this.getExecutionSettings();
|
|
129
|
+
return settings.profiles[profileId] || {
|
|
130
|
+
executionMode: 'on_demand',
|
|
131
|
+
scheduleType: 'interval',
|
|
132
|
+
scheduleInterval: 60,
|
|
133
|
+
cronExpression: '0 * * * *',
|
|
134
|
+
lastExecutedAt: null,
|
|
135
|
+
nextExecutionAt: null,
|
|
136
|
+
enabled: false,
|
|
137
|
+
syncDependencies: false,
|
|
138
|
+
dependencyDepth: 1,
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Update execution settings for a profile
|
|
144
|
+
*/
|
|
145
|
+
async setProfileExecutionSettings(profileId, executionSettings) {
|
|
146
|
+
const store = getStore();
|
|
147
|
+
const settings = await this.getExecutionSettings();
|
|
148
|
+
|
|
149
|
+
// Validate execution mode
|
|
150
|
+
if (executionSettings.executionMode && !VALID_EXECUTION_MODES.includes(executionSettings.executionMode)) {
|
|
151
|
+
throw new Error(`Invalid execution mode "${executionSettings.executionMode}"`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Validate schedule type
|
|
155
|
+
if (executionSettings.scheduleType && !VALID_SCHEDULE_TYPES.includes(executionSettings.scheduleType)) {
|
|
156
|
+
throw new Error(`Invalid schedule type "${executionSettings.scheduleType}". Must be one of: ${VALID_SCHEDULE_TYPES.join(', ')}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Validate schedule interval (used by interval & timeout types)
|
|
160
|
+
if (executionSettings.scheduleInterval !== undefined) {
|
|
161
|
+
if (executionSettings.scheduleInterval < 1 || executionSettings.scheduleInterval > 1440) {
|
|
162
|
+
throw new Error('Schedule interval must be between 1 and 1440 minutes');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Validate cron expression when provided
|
|
167
|
+
if (executionSettings.cronExpression !== undefined && executionSettings.cronExpression !== null && executionSettings.cronExpression !== '') {
|
|
168
|
+
if (!isValidCronExpression(executionSettings.cronExpression)) {
|
|
169
|
+
throw new Error('Invalid cron expression. Expected 5 or 6 space-separated fields (e.g. "0 */2 * * *")');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Validate dependency depth
|
|
174
|
+
if (executionSettings.dependencyDepth !== undefined) {
|
|
175
|
+
if (executionSettings.dependencyDepth < 1 || executionSettings.dependencyDepth > 5) {
|
|
176
|
+
throw new Error('Dependency depth must be between 1 and 5');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const current = settings.profiles[profileId] || {};
|
|
181
|
+
const merged = {
|
|
182
|
+
scheduleType: 'interval',
|
|
183
|
+
scheduleInterval: 60,
|
|
184
|
+
...current,
|
|
185
|
+
...executionSettings,
|
|
186
|
+
updatedAt: new Date().toISOString(),
|
|
187
|
+
};
|
|
188
|
+
settings.profiles[profileId] = merged;
|
|
189
|
+
|
|
190
|
+
// Calculate an advisory nextExecutionAt for scheduled mode
|
|
191
|
+
if (merged.executionMode === 'scheduled' && merged.enabled) {
|
|
192
|
+
if (merged.scheduleType === 'interval' || merged.scheduleType === 'timeout') {
|
|
193
|
+
const intervalMs = (merged.scheduleInterval || 60) * 60 * 1000;
|
|
194
|
+
merged.nextExecutionAt = new Date(Date.now() + intervalMs).toISOString();
|
|
195
|
+
} else if (merged.scheduleType === 'external') {
|
|
196
|
+
merged.nextExecutionAt = null;
|
|
197
|
+
}
|
|
198
|
+
// cron: leave nextExecutionAt as-is; node-schedule owns the timing
|
|
199
|
+
} else {
|
|
200
|
+
merged.nextExecutionAt = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await store.set({ key: STORE_KEY, value: settings });
|
|
204
|
+
|
|
205
|
+
// Update scheduler if needed
|
|
206
|
+
await this.updateScheduler(profileId, settings.profiles[profileId]);
|
|
207
|
+
|
|
208
|
+
return settings.profiles[profileId];
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get global execution settings
|
|
213
|
+
*/
|
|
214
|
+
async getGlobalSettings() {
|
|
215
|
+
const settings = await this.getExecutionSettings();
|
|
216
|
+
return settings.globalSettings || DEFAULT_GLOBAL_SETTINGS;
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Update global execution settings
|
|
221
|
+
*/
|
|
222
|
+
async setGlobalSettings(globalSettings) {
|
|
223
|
+
const store = getStore();
|
|
224
|
+
const settings = await this.getExecutionSettings();
|
|
225
|
+
|
|
226
|
+
settings.globalSettings = {
|
|
227
|
+
...settings.globalSettings,
|
|
228
|
+
...globalSettings,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
await store.set({ key: STORE_KEY, value: settings });
|
|
232
|
+
return settings.globalSettings;
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Execute a profile on-demand
|
|
237
|
+
*/
|
|
238
|
+
async executeProfile(profileId, options = {}) {
|
|
239
|
+
const syncService = plugin().service('sync');
|
|
240
|
+
const profilesService = plugin().service('syncProfiles');
|
|
241
|
+
const alertsService = plugin().service('alerts');
|
|
242
|
+
const logService = plugin().service('syncLog');
|
|
243
|
+
|
|
244
|
+
const profile = await profilesService.getProfile(profileId);
|
|
245
|
+
if (!profile) {
|
|
246
|
+
throw new Error(`Profile with id "${profileId}" not found`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const executionSettings = await this.getProfileExecutionSettings(profileId);
|
|
250
|
+
const syncDependencies = options.syncDependencies ?? executionSettings.syncDependencies;
|
|
251
|
+
const dependencyDepth = options.dependencyDepth ?? executionSettings.dependencyDepth ?? 1;
|
|
252
|
+
|
|
253
|
+
const startTime = new Date();
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
// Log execution start
|
|
257
|
+
await logService.log({
|
|
258
|
+
action: 'execution_start',
|
|
259
|
+
contentType: profile.contentType,
|
|
260
|
+
direction: profile.direction,
|
|
261
|
+
status: 'info',
|
|
262
|
+
message: `Starting sync for profile: ${profile.name}`,
|
|
263
|
+
details: { profileId, syncDependencies, dependencyDepth },
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Execute sync
|
|
267
|
+
const result = await syncService.syncContentType(profile.contentType, {
|
|
268
|
+
profile,
|
|
269
|
+
syncDependencies,
|
|
270
|
+
dependencyDepth,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Update last execution time
|
|
274
|
+
await this.updateLastExecution(profileId);
|
|
275
|
+
|
|
276
|
+
// Send success alert
|
|
277
|
+
await alertsService.sendAlert('sync_success', {
|
|
278
|
+
profile: profile.name,
|
|
279
|
+
contentType: profile.contentType,
|
|
280
|
+
result,
|
|
281
|
+
duration: Date.now() - startTime.getTime(),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return result;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// Send failure alert
|
|
287
|
+
await alertsService.sendAlert('sync_failure', {
|
|
288
|
+
profile: profile.name,
|
|
289
|
+
contentType: profile.contentType,
|
|
290
|
+
error: error.message,
|
|
291
|
+
duration: Date.now() - startTime.getTime(),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Execute multiple profiles
|
|
300
|
+
*/
|
|
301
|
+
async executeProfiles(profileIds, options = {}) {
|
|
302
|
+
const globalSettings = await this.getGlobalSettings();
|
|
303
|
+
const results = [];
|
|
304
|
+
const errors = [];
|
|
305
|
+
|
|
306
|
+
// Simple sequential execution (can be enhanced with concurrency control)
|
|
307
|
+
for (const profileId of profileIds) {
|
|
308
|
+
try {
|
|
309
|
+
const result = await this.executeProfile(profileId, options);
|
|
310
|
+
results.push({ profileId, success: true, result });
|
|
311
|
+
} catch (error) {
|
|
312
|
+
errors.push({ profileId, success: false, error: error.message });
|
|
313
|
+
|
|
314
|
+
if (!globalSettings.retryOnFailure) continue;
|
|
315
|
+
|
|
316
|
+
// Retry logic
|
|
317
|
+
for (let attempt = 1; attempt <= globalSettings.retryAttempts; attempt++) {
|
|
318
|
+
await new Promise(resolve => setTimeout(resolve, globalSettings.retryDelayMs));
|
|
319
|
+
try {
|
|
320
|
+
const result = await this.executeProfile(profileId, options);
|
|
321
|
+
results.push({ profileId, success: true, result, retryAttempt: attempt });
|
|
322
|
+
break;
|
|
323
|
+
} catch (retryError) {
|
|
324
|
+
if (attempt === globalSettings.retryAttempts) {
|
|
325
|
+
errors.push({ profileId, success: false, error: retryError.message, finalFailure: true });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { results, errors };
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Execute all active profiles for a content type
|
|
337
|
+
*/
|
|
338
|
+
async executeContentType(contentTypeUid, options = {}) {
|
|
339
|
+
const profilesService = plugin().service('syncProfiles');
|
|
340
|
+
const profile = await profilesService.getActiveProfileForContentType(contentTypeUid);
|
|
341
|
+
|
|
342
|
+
if (!profile) {
|
|
343
|
+
throw new Error(`No active profile for content type "${contentTypeUid}"`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return this.executeProfile(profile.id, options);
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Update last execution timestamp
|
|
351
|
+
*/
|
|
352
|
+
async updateLastExecution(profileId) {
|
|
353
|
+
const store = getStore();
|
|
354
|
+
const settings = await this.getExecutionSettings();
|
|
355
|
+
|
|
356
|
+
if (settings.profiles[profileId]) {
|
|
357
|
+
const prof = settings.profiles[profileId];
|
|
358
|
+
prof.lastExecutedAt = new Date().toISOString();
|
|
359
|
+
|
|
360
|
+
// Only refresh nextExecutionAt for interval/timeout schedules; cron/external
|
|
361
|
+
// advance their own next run externally.
|
|
362
|
+
if (
|
|
363
|
+
prof.executionMode === 'scheduled' &&
|
|
364
|
+
prof.enabled &&
|
|
365
|
+
(prof.scheduleType === 'interval' || prof.scheduleType === 'timeout' || !prof.scheduleType)
|
|
366
|
+
) {
|
|
367
|
+
const intervalMs = (prof.scheduleInterval || 60) * 60 * 1000;
|
|
368
|
+
prof.nextExecutionAt = new Date(Date.now() + intervalMs).toISOString();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
await store.set({ key: STORE_KEY, value: settings });
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Update scheduler for a profile (start/stop scheduled execution).
|
|
377
|
+
* Dispatches to one of four scheduler types based on executionSettings.scheduleType.
|
|
378
|
+
*/
|
|
379
|
+
async updateScheduler(profileId, executionSettings) {
|
|
380
|
+
// Always clear any existing handles for this profile
|
|
381
|
+
clearHandles(profileId);
|
|
382
|
+
|
|
383
|
+
if (!(executionSettings.executionMode === 'scheduled' && executionSettings.enabled)) {
|
|
384
|
+
// Still handle live mode registration
|
|
385
|
+
if (executionSettings.executionMode === 'live' && executionSettings.enabled) {
|
|
386
|
+
await this.registerLiveHooks();
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const scheduleType = executionSettings.scheduleType || 'interval';
|
|
392
|
+
const intervalMinutes = executionSettings.scheduleInterval || 60;
|
|
393
|
+
const intervalMs = intervalMinutes * 60 * 1000;
|
|
394
|
+
const self = this;
|
|
395
|
+
|
|
396
|
+
switch (scheduleType) {
|
|
397
|
+
case 'interval': {
|
|
398
|
+
const id = setInterval(() => {
|
|
399
|
+
runSafely(profileId, () => self.executeProfile(profileId));
|
|
400
|
+
}, intervalMs);
|
|
401
|
+
schedulerHandles[profileId] = { intervalId: id };
|
|
402
|
+
strapi.log.info(`[data-sync] interval scheduler enabled for profile ${profileId}: every ${intervalMinutes} min`);
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
case 'timeout': {
|
|
407
|
+
// Chained timeout: schedule next run only AFTER previous completes.
|
|
408
|
+
// Prevents overlap for long-running syncs.
|
|
409
|
+
const scheduleNext = () => {
|
|
410
|
+
const tid = setTimeout(async () => {
|
|
411
|
+
await runSafely(profileId, () => self.executeProfile(profileId));
|
|
412
|
+
// Re-schedule only if this profile is still active and in timeout mode
|
|
413
|
+
const latest = await self.getProfileExecutionSettings(profileId);
|
|
414
|
+
if (
|
|
415
|
+
latest.executionMode === 'scheduled' &&
|
|
416
|
+
latest.enabled &&
|
|
417
|
+
latest.scheduleType === 'timeout' &&
|
|
418
|
+
schedulerHandles[profileId] // not cleared in between
|
|
419
|
+
) {
|
|
420
|
+
scheduleNext();
|
|
421
|
+
}
|
|
422
|
+
}, intervalMs);
|
|
423
|
+
schedulerHandles[profileId] = { timeoutId: tid };
|
|
424
|
+
};
|
|
425
|
+
scheduleNext();
|
|
426
|
+
strapi.log.info(`[data-sync] timeout (chained) scheduler enabled for profile ${profileId}: ~${intervalMinutes} min between runs`);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
case 'cron': {
|
|
431
|
+
const expr = executionSettings.cronExpression;
|
|
432
|
+
if (!expr || !isValidCronExpression(expr)) {
|
|
433
|
+
strapi.log.error(`[data-sync] Cron scheduler NOT started for profile ${profileId}: invalid or missing cronExpression`);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (!strapi.cron || typeof strapi.cron.add !== 'function') {
|
|
437
|
+
strapi.log.error(`[data-sync] strapi.cron is not available; cannot start cron scheduler for profile ${profileId}`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const cronKey = `data-sync:profile:${profileId}`;
|
|
441
|
+
try {
|
|
442
|
+
strapi.cron.add({
|
|
443
|
+
[cronKey]: {
|
|
444
|
+
task: async () => {
|
|
445
|
+
await runSafely(profileId, () => self.executeProfile(profileId));
|
|
446
|
+
},
|
|
447
|
+
options: { rule: expr },
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
schedulerHandles[profileId] = { cronKey };
|
|
451
|
+
strapi.log.info(`[data-sync] cron scheduler enabled for profile ${profileId}: "${expr}"`);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
strapi.log.error(`[data-sync] Failed to register cron for profile ${profileId}: ${err.message}`);
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
case 'external': {
|
|
459
|
+
// No in-process scheduler. The user will invoke the execute endpoint
|
|
460
|
+
// from an external scheduler (systemd, Windows Task Scheduler, k8s,
|
|
461
|
+
// cloud scheduler, CI, etc.). Mark the handles so getExecutionStatus
|
|
462
|
+
// can report this distinctly.
|
|
463
|
+
schedulerHandles[profileId] = { external: true };
|
|
464
|
+
strapi.log.info(`[data-sync] external scheduler selected for profile ${profileId}: no in-process timer will run`);
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
default:
|
|
469
|
+
strapi.log.warn(`[data-sync] Unknown scheduleType "${scheduleType}" for profile ${profileId}`);
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Register lifecycle hooks for live sync
|
|
475
|
+
*/
|
|
476
|
+
async registerLiveHooks() {
|
|
477
|
+
if (liveHooksRegistered) return;
|
|
478
|
+
|
|
479
|
+
// Note: Lifecycle hooks should be registered during bootstrap
|
|
480
|
+
// This is a placeholder - actual implementation requires Strapi lifecycle API
|
|
481
|
+
strapi.log.info('Live sync hooks registration requested (requires bootstrap setup)');
|
|
482
|
+
liveHooksRegistered = true;
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Initialize all scheduled syncs on startup
|
|
487
|
+
*/
|
|
488
|
+
async initializeSchedulers() {
|
|
489
|
+
const settings = await this.getExecutionSettings();
|
|
490
|
+
|
|
491
|
+
for (const [profileId, execSettings] of Object.entries(settings.profiles)) {
|
|
492
|
+
if (
|
|
493
|
+
(execSettings.executionMode === 'scheduled' && execSettings.enabled) ||
|
|
494
|
+
(execSettings.executionMode === 'live' && execSettings.enabled)
|
|
495
|
+
) {
|
|
496
|
+
await this.updateScheduler(profileId, execSettings);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Stop all schedulers (for shutdown)
|
|
503
|
+
*/
|
|
504
|
+
stopAllSchedulers() {
|
|
505
|
+
for (const profileId of Object.keys(schedulerHandles)) {
|
|
506
|
+
clearHandles(profileId);
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Get execution status for all profiles
|
|
512
|
+
*/
|
|
513
|
+
async getExecutionStatus() {
|
|
514
|
+
const settings = await this.getExecutionSettings();
|
|
515
|
+
const profilesService = plugin().service('syncProfiles');
|
|
516
|
+
const profiles = await profilesService.getProfiles();
|
|
517
|
+
|
|
518
|
+
const status = [];
|
|
519
|
+
for (const profile of profiles) {
|
|
520
|
+
const execSettings = settings.profiles[profile.id] || {};
|
|
521
|
+
const handle = schedulerHandles[profile.id];
|
|
522
|
+
status.push({
|
|
523
|
+
profileId: profile.id,
|
|
524
|
+
profileName: profile.name,
|
|
525
|
+
contentType: profile.contentType,
|
|
526
|
+
executionMode: execSettings.executionMode || 'on_demand',
|
|
527
|
+
scheduleType: execSettings.scheduleType || null,
|
|
528
|
+
scheduleInterval: execSettings.scheduleInterval || null,
|
|
529
|
+
cronExpression: execSettings.cronExpression || null,
|
|
530
|
+
enabled: execSettings.enabled || false,
|
|
531
|
+
lastExecutedAt: execSettings.lastExecutedAt || null,
|
|
532
|
+
nextExecutionAt: execSettings.nextExecutionAt || null,
|
|
533
|
+
isSchedulerRunning: !!handle && !handle.external,
|
|
534
|
+
isExternal: !!(handle && handle.external),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return status;
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const CONTENT_TYPE_UID = 'plugin::strapi-content-sync-pro.sync-log';
|
|
4
|
+
|
|
5
|
+
module.exports = ({ strapi }) => ({
|
|
6
|
+
async log({ action, contentType, recordId, syncId, direction, status, message, details }) {
|
|
7
|
+
try {
|
|
8
|
+
await strapi.documents(CONTENT_TYPE_UID).create({
|
|
9
|
+
data: {
|
|
10
|
+
action: action || 'unknown',
|
|
11
|
+
contentType: contentType || '',
|
|
12
|
+
recordId: recordId || '',
|
|
13
|
+
syncId: syncId || '',
|
|
14
|
+
direction: direction || '',
|
|
15
|
+
status: status || 'info',
|
|
16
|
+
message: message || '',
|
|
17
|
+
details: details || null,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
} catch (err) {
|
|
21
|
+
strapi.log.error(`[data-sync] Failed to write log: ${err.message}`);
|
|
22
|
+
strapi.log.info(`[data-sync] ${action} | ${contentType} | ${status} | ${message}`);
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async getLogs({ page = 1, pageSize = 25, status, contentType, action } = {}) {
|
|
27
|
+
const filters = {};
|
|
28
|
+
if (status) filters.status = status;
|
|
29
|
+
if (contentType) filters.contentType = contentType;
|
|
30
|
+
if (action) filters.action = action;
|
|
31
|
+
|
|
32
|
+
const start = (page - 1) * pageSize;
|
|
33
|
+
|
|
34
|
+
const [entries, count] = await Promise.all([
|
|
35
|
+
strapi.documents(CONTENT_TYPE_UID).findMany({
|
|
36
|
+
filters,
|
|
37
|
+
sort: { createdAt: 'desc' },
|
|
38
|
+
limit: pageSize,
|
|
39
|
+
start,
|
|
40
|
+
}),
|
|
41
|
+
strapi.documents(CONTENT_TYPE_UID).count({ filters }),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
data: entries,
|
|
46
|
+
meta: {
|
|
47
|
+
pagination: {
|
|
48
|
+
page,
|
|
49
|
+
pageSize,
|
|
50
|
+
pageCount: Math.ceil(count / pageSize),
|
|
51
|
+
total: count,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
});
|