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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/admin/src/components/ConfigTab.jsx +1038 -0
  4. package/admin/src/components/ContentTypesTab.jsx +160 -0
  5. package/admin/src/components/HelpTab.jsx +945 -0
  6. package/admin/src/components/LogsTab.jsx +136 -0
  7. package/admin/src/components/MediaTab.jsx +557 -0
  8. package/admin/src/components/SyncProfilesTab.jsx +715 -0
  9. package/admin/src/components/SyncTab.jsx +988 -0
  10. package/admin/src/index.js +31 -0
  11. package/admin/src/pages/App/index.jsx +129 -0
  12. package/admin/src/pluginId.js +3 -0
  13. package/package.json +84 -0
  14. package/server/src/bootstrap.js +151 -0
  15. package/server/src/config/index.js +5 -0
  16. package/server/src/content-types/index.js +7 -0
  17. package/server/src/content-types/sync-log/schema.json +24 -0
  18. package/server/src/controllers/alerts.js +59 -0
  19. package/server/src/controllers/config.js +292 -0
  20. package/server/src/controllers/content-type-discovery.js +9 -0
  21. package/server/src/controllers/dependencies.js +109 -0
  22. package/server/src/controllers/index.js +29 -0
  23. package/server/src/controllers/ping.js +7 -0
  24. package/server/src/controllers/sync-config.js +26 -0
  25. package/server/src/controllers/sync-enforcement.js +323 -0
  26. package/server/src/controllers/sync-execution.js +134 -0
  27. package/server/src/controllers/sync-log.js +18 -0
  28. package/server/src/controllers/sync-media.js +158 -0
  29. package/server/src/controllers/sync-profiles.js +182 -0
  30. package/server/src/controllers/sync.js +31 -0
  31. package/server/src/destroy.js +7 -0
  32. package/server/src/index.js +21 -0
  33. package/server/src/middlewares/verify-signature.js +32 -0
  34. package/server/src/register.js +7 -0
  35. package/server/src/routes/index.js +111 -0
  36. package/server/src/services/alerts.js +437 -0
  37. package/server/src/services/config.js +68 -0
  38. package/server/src/services/content-type-discovery.js +41 -0
  39. package/server/src/services/dependency-resolver.js +284 -0
  40. package/server/src/services/index.js +30 -0
  41. package/server/src/services/ping.js +7 -0
  42. package/server/src/services/sync-config.js +45 -0
  43. package/server/src/services/sync-enforcement.js +362 -0
  44. package/server/src/services/sync-execution.js +541 -0
  45. package/server/src/services/sync-log.js +56 -0
  46. package/server/src/services/sync-media.js +963 -0
  47. package/server/src/services/sync-profiles.js +380 -0
  48. package/server/src/services/sync.js +248 -0
  49. package/server/src/utils/applier.js +89 -0
  50. package/server/src/utils/comparator.js +83 -0
  51. package/server/src/utils/fetcher.js +142 -0
  52. package/server/src/utils/hmac.js +37 -0
  53. package/server/src/utils/pagination.js +51 -0
  54. package/server/src/utils/sync-guard.js +29 -0
  55. 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
+ });