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,380 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const STORE_KEY = 'sync-profiles';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sync Profiles Service
|
|
7
|
+
*
|
|
8
|
+
* A profile defines WHAT and HOW to sync for a content type:
|
|
9
|
+
* - direction: push, pull, both
|
|
10
|
+
* - conflictStrategy: latest, local_wins, remote_wins
|
|
11
|
+
* - fieldPolicies: per-field direction overrides (advanced mode)
|
|
12
|
+
*
|
|
13
|
+
* Execution settings (WHEN to sync) are managed separately in sync-execution service.
|
|
14
|
+
*
|
|
15
|
+
* Profile structure:
|
|
16
|
+
* {
|
|
17
|
+
* id: string,
|
|
18
|
+
* name: string,
|
|
19
|
+
* contentType: string (uid),
|
|
20
|
+
* direction: 'push' | 'pull' | 'both',
|
|
21
|
+
* conflictStrategy: 'latest' | 'local_wins' | 'remote_wins',
|
|
22
|
+
* isActive: boolean,
|
|
23
|
+
* isSimple: boolean (false = advanced mode with field policies),
|
|
24
|
+
* fieldPolicies: [{ field, direction }],
|
|
25
|
+
* createdAt: ISO string,
|
|
26
|
+
* updatedAt: ISO string
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
module.exports = ({ strapi }) => {
|
|
30
|
+
function getStore() {
|
|
31
|
+
return strapi.store({ type: 'plugin', name: 'strapi-content-sync-pro' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function generateId() {
|
|
35
|
+
return `profile_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const VALID_DIRECTIONS = ['push', 'pull', 'both', 'none'];
|
|
39
|
+
const VALID_CONFLICT_STRATEGIES = ['latest', 'local_wins', 'remote_wins'];
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
/**
|
|
43
|
+
* Get all sync profiles
|
|
44
|
+
*/
|
|
45
|
+
async getProfiles() {
|
|
46
|
+
const store = getStore();
|
|
47
|
+
const data = await store.get({ key: STORE_KEY });
|
|
48
|
+
return data || [];
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get a single profile by ID
|
|
53
|
+
*/
|
|
54
|
+
async getProfile(id) {
|
|
55
|
+
const profiles = await this.getProfiles();
|
|
56
|
+
return profiles.find((p) => p.id === id) || null;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get active profile for a content type
|
|
61
|
+
*/
|
|
62
|
+
async getActiveProfileForContentType(contentTypeUid) {
|
|
63
|
+
const profiles = await this.getProfiles();
|
|
64
|
+
return profiles.find((p) => p.contentType === contentTypeUid && p.isActive) || null;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get all profiles for a content type
|
|
69
|
+
*/
|
|
70
|
+
async getProfilesForContentType(contentTypeUid) {
|
|
71
|
+
const profiles = await this.getProfiles();
|
|
72
|
+
return profiles.filter((p) => p.contentType === contentTypeUid);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Auto-generate default profiles for a content type
|
|
77
|
+
* Creates: Full Push, Full Pull, Bidirectional (Merge)
|
|
78
|
+
*/
|
|
79
|
+
async autoGenerateProfiles(contentTypeUid) {
|
|
80
|
+
const existingProfiles = await this.getProfilesForContentType(contentTypeUid);
|
|
81
|
+
if (existingProfiles.length > 0) {
|
|
82
|
+
return existingProfiles; // Don't regenerate if profiles exist
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const contentType = strapi.contentTypes[contentTypeUid];
|
|
86
|
+
const displayName = contentType?.info?.displayName || contentTypeUid;
|
|
87
|
+
|
|
88
|
+
const defaultProfiles = [
|
|
89
|
+
{
|
|
90
|
+
name: `${displayName} - Full Push`,
|
|
91
|
+
contentType: contentTypeUid,
|
|
92
|
+
direction: 'push',
|
|
93
|
+
conflictStrategy: 'local_wins',
|
|
94
|
+
isActive: false,
|
|
95
|
+
isSimple: true,
|
|
96
|
+
fieldPolicies: [],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: `${displayName} - Full Pull`,
|
|
100
|
+
contentType: contentTypeUid,
|
|
101
|
+
direction: 'pull',
|
|
102
|
+
conflictStrategy: 'remote_wins',
|
|
103
|
+
isActive: false,
|
|
104
|
+
isSimple: true,
|
|
105
|
+
fieldPolicies: [],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: `${displayName} - Bidirectional`,
|
|
109
|
+
contentType: contentTypeUid,
|
|
110
|
+
direction: 'both',
|
|
111
|
+
conflictStrategy: 'latest',
|
|
112
|
+
isActive: true, // Default active profile
|
|
113
|
+
isSimple: true,
|
|
114
|
+
fieldPolicies: [],
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const created = [];
|
|
119
|
+
for (const profileData of defaultProfiles) {
|
|
120
|
+
const profile = await this.createProfile(profileData);
|
|
121
|
+
created.push(profile);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return created;
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a new sync profile
|
|
129
|
+
*/
|
|
130
|
+
async createProfile(profileData) {
|
|
131
|
+
const store = getStore();
|
|
132
|
+
const profiles = await this.getProfiles();
|
|
133
|
+
|
|
134
|
+
if (!profileData.name) {
|
|
135
|
+
throw new Error('Profile name is required');
|
|
136
|
+
}
|
|
137
|
+
if (!profileData.contentType) {
|
|
138
|
+
throw new Error('Content type is required');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Validate direction
|
|
142
|
+
if (profileData.direction && !['push', 'pull', 'both'].includes(profileData.direction)) {
|
|
143
|
+
throw new Error(`Invalid direction "${profileData.direction}"`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Validate conflict strategy
|
|
147
|
+
if (profileData.conflictStrategy && !VALID_CONFLICT_STRATEGIES.includes(profileData.conflictStrategy)) {
|
|
148
|
+
throw new Error(`Invalid conflict strategy "${profileData.conflictStrategy}"`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Validate field policies
|
|
152
|
+
if (profileData.fieldPolicies) {
|
|
153
|
+
for (const fp of profileData.fieldPolicies) {
|
|
154
|
+
if (!fp.field) {
|
|
155
|
+
throw new Error('Each field policy must have a field name');
|
|
156
|
+
}
|
|
157
|
+
if (fp.direction && !VALID_DIRECTIONS.includes(fp.direction)) {
|
|
158
|
+
throw new Error(`Invalid direction "${fp.direction}" for field "${fp.field}"`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const newProfile = {
|
|
164
|
+
id: generateId(),
|
|
165
|
+
name: profileData.name,
|
|
166
|
+
contentType: profileData.contentType,
|
|
167
|
+
direction: profileData.direction || 'both',
|
|
168
|
+
conflictStrategy: profileData.conflictStrategy || 'latest',
|
|
169
|
+
isActive: profileData.isActive || false,
|
|
170
|
+
isSimple: profileData.isSimple !== false, // Default to simple mode
|
|
171
|
+
fieldPolicies: (profileData.fieldPolicies || []).map((fp) => ({
|
|
172
|
+
field: fp.field,
|
|
173
|
+
direction: fp.direction || 'both',
|
|
174
|
+
})),
|
|
175
|
+
createdAt: new Date().toISOString(),
|
|
176
|
+
updatedAt: new Date().toISOString(),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// If this profile is set as active, deactivate others for same content type
|
|
180
|
+
if (newProfile.isActive) {
|
|
181
|
+
profiles.forEach((p) => {
|
|
182
|
+
if (p.contentType === newProfile.contentType) {
|
|
183
|
+
p.isActive = false;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
profiles.push(newProfile);
|
|
189
|
+
await store.set({ key: STORE_KEY, value: profiles });
|
|
190
|
+
|
|
191
|
+
return newProfile;
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Update an existing sync profile
|
|
196
|
+
*/
|
|
197
|
+
async updateProfile(id, updates) {
|
|
198
|
+
const store = getStore();
|
|
199
|
+
const profiles = await this.getProfiles();
|
|
200
|
+
const index = profiles.findIndex((p) => p.id === id);
|
|
201
|
+
|
|
202
|
+
if (index === -1) {
|
|
203
|
+
throw new Error(`Profile with id "${id}" not found`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Validate direction
|
|
207
|
+
if (updates.direction && !['push', 'pull', 'both'].includes(updates.direction)) {
|
|
208
|
+
throw new Error(`Invalid direction "${updates.direction}"`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Validate conflict strategy
|
|
212
|
+
if (updates.conflictStrategy && !VALID_CONFLICT_STRATEGIES.includes(updates.conflictStrategy)) {
|
|
213
|
+
throw new Error(`Invalid conflict strategy "${updates.conflictStrategy}"`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Validate field policies if provided
|
|
217
|
+
if (updates.fieldPolicies) {
|
|
218
|
+
for (const fp of updates.fieldPolicies) {
|
|
219
|
+
if (!fp.field) {
|
|
220
|
+
throw new Error('Each field policy must have a field name');
|
|
221
|
+
}
|
|
222
|
+
if (fp.direction && !VALID_DIRECTIONS.includes(fp.direction)) {
|
|
223
|
+
throw new Error(`Invalid direction "${fp.direction}" for field "${fp.field}"`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// If setting this profile as active, deactivate others for same content type
|
|
229
|
+
if (updates.isActive) {
|
|
230
|
+
const contentType = updates.contentType || profiles[index].contentType;
|
|
231
|
+
profiles.forEach((p) => {
|
|
232
|
+
if (p.contentType === contentType && p.id !== id) {
|
|
233
|
+
p.isActive = false;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const updatedProfile = {
|
|
239
|
+
...profiles[index],
|
|
240
|
+
...updates,
|
|
241
|
+
id: profiles[index].id, // prevent id change
|
|
242
|
+
createdAt: profiles[index].createdAt, // preserve creation date
|
|
243
|
+
updatedAt: new Date().toISOString(),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
if (updates.fieldPolicies) {
|
|
247
|
+
updatedProfile.fieldPolicies = updates.fieldPolicies.map((fp) => ({
|
|
248
|
+
field: fp.field,
|
|
249
|
+
direction: fp.direction || 'both',
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
profiles[index] = updatedProfile;
|
|
254
|
+
await store.set({ key: STORE_KEY, value: profiles });
|
|
255
|
+
|
|
256
|
+
return updatedProfile;
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Delete a sync profile
|
|
261
|
+
*/
|
|
262
|
+
async deleteProfile(id) {
|
|
263
|
+
const store = getStore();
|
|
264
|
+
const profiles = await this.getProfiles();
|
|
265
|
+
const filtered = profiles.filter((p) => p.id !== id);
|
|
266
|
+
|
|
267
|
+
if (filtered.length === profiles.length) {
|
|
268
|
+
throw new Error(`Profile with id "${id}" not found`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await store.set({ key: STORE_KEY, value: filtered });
|
|
272
|
+
return { success: true };
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Create a simple preset profile
|
|
277
|
+
*/
|
|
278
|
+
async createSimpleProfile(contentTypeUid, preset) {
|
|
279
|
+
const contentType = strapi.contentTypes[contentTypeUid];
|
|
280
|
+
const displayName = contentType?.info?.displayName || contentTypeUid;
|
|
281
|
+
|
|
282
|
+
const presets = {
|
|
283
|
+
full_push: {
|
|
284
|
+
name: `${displayName} - Full Push`,
|
|
285
|
+
direction: 'push',
|
|
286
|
+
conflictStrategy: 'local_wins',
|
|
287
|
+
},
|
|
288
|
+
full_pull: {
|
|
289
|
+
name: `${displayName} - Full Pull`,
|
|
290
|
+
direction: 'pull',
|
|
291
|
+
conflictStrategy: 'remote_wins',
|
|
292
|
+
},
|
|
293
|
+
bidirectional: {
|
|
294
|
+
name: `${displayName} - Bidirectional`,
|
|
295
|
+
direction: 'both',
|
|
296
|
+
conflictStrategy: 'latest',
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const presetConfig = presets[preset];
|
|
301
|
+
if (!presetConfig) {
|
|
302
|
+
throw new Error(`Invalid preset "${preset}". Valid presets: ${Object.keys(presets).join(', ')}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return this.createProfile({
|
|
306
|
+
...presetConfig,
|
|
307
|
+
contentType: contentTypeUid,
|
|
308
|
+
isSimple: true,
|
|
309
|
+
isActive: false,
|
|
310
|
+
fieldPolicies: [],
|
|
311
|
+
});
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Get field policies for a content type (from active profile)
|
|
316
|
+
* Returns a map: { fieldName: 'push' | 'pull' | 'both' | 'none' }
|
|
317
|
+
*/
|
|
318
|
+
async getFieldPoliciesForContentType(contentTypeUid) {
|
|
319
|
+
const activeProfile = await this.getActiveProfileForContentType(contentTypeUid);
|
|
320
|
+
if (!activeProfile || activeProfile.isSimple) {
|
|
321
|
+
return null; // No field policies for simple profiles
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const policyMap = {};
|
|
325
|
+
for (const fp of activeProfile.fieldPolicies) {
|
|
326
|
+
policyMap[fp.field] = fp.direction;
|
|
327
|
+
}
|
|
328
|
+
return policyMap;
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Get sync configuration for a content type (from active profile)
|
|
333
|
+
*/
|
|
334
|
+
async getSyncConfigForContentType(contentTypeUid) {
|
|
335
|
+
const activeProfile = await this.getActiveProfileForContentType(contentTypeUid);
|
|
336
|
+
if (!activeProfile) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
direction: activeProfile.direction,
|
|
342
|
+
conflictStrategy: activeProfile.conflictStrategy,
|
|
343
|
+
fieldPolicies: activeProfile.isSimple ? null : await this.getFieldPoliciesForContentType(contentTypeUid),
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Filter fields based on policies for a given direction
|
|
349
|
+
*/
|
|
350
|
+
filterFieldsByPolicy(record, fieldPolicies, syncDirection) {
|
|
351
|
+
if (!fieldPolicies) {
|
|
352
|
+
return record; // No policies, return all fields
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const filtered = {};
|
|
356
|
+
for (const [field, value] of Object.entries(record)) {
|
|
357
|
+
const policy = fieldPolicies[field];
|
|
358
|
+
|
|
359
|
+
// If no policy defined for field, include it (default to 'both')
|
|
360
|
+
if (!policy || policy === 'both') {
|
|
361
|
+
filtered[field] = value;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Include field if policy matches sync direction
|
|
366
|
+
if (policy === syncDirection) {
|
|
367
|
+
filtered[field] = value;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Always include id and metadata fields
|
|
372
|
+
if (['id', 'documentId', 'syncId', 'createdAt', 'updatedAt'].includes(field)) {
|
|
373
|
+
filtered[field] = value;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return filtered;
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { fetchLocalRecords, fetchRemoteRecords } = require('../utils/fetcher');
|
|
4
|
+
const { compareRecords } = require('../utils/comparator');
|
|
5
|
+
const { applyLocal, applyRemote } = require('../utils/applier');
|
|
6
|
+
|
|
7
|
+
const LAST_SYNC_STORE_KEY = 'last-sync-timestamps';
|
|
8
|
+
|
|
9
|
+
module.exports = ({ strapi }) => {
|
|
10
|
+
function getStore() {
|
|
11
|
+
return strapi.store({ type: 'plugin', name: 'strapi-content-sync-pro' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function plugin() {
|
|
15
|
+
return strapi.plugin('strapi-content-sync-pro');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function getLastSyncTimestamps() {
|
|
19
|
+
const store = getStore();
|
|
20
|
+
return (await store.get({ key: LAST_SYNC_STORE_KEY })) || {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function setLastSyncTimestamp(uid, timestamp) {
|
|
24
|
+
const store = getStore();
|
|
25
|
+
const timestamps = await getLastSyncTimestamps();
|
|
26
|
+
timestamps[uid] = timestamp;
|
|
27
|
+
await store.set({ key: LAST_SYNC_STORE_KEY, value: timestamps });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
/**
|
|
32
|
+
* Step 6 + 7 + 10 — Execute a manual / incremental sync for every
|
|
33
|
+
* enabled content type.
|
|
34
|
+
*
|
|
35
|
+
* Now supports field-level policies from Sync Profiles.
|
|
36
|
+
*/
|
|
37
|
+
async syncNow() {
|
|
38
|
+
const logService = plugin().service('syncLog');
|
|
39
|
+
const configService = plugin().service('config');
|
|
40
|
+
const syncConfigService = plugin().service('syncConfig');
|
|
41
|
+
const syncProfilesService = plugin().service('syncProfiles');
|
|
42
|
+
const executionService = plugin().service('syncExecution');
|
|
43
|
+
|
|
44
|
+
const remoteConfig = await configService.getConfig({ safe: false });
|
|
45
|
+
if (!remoteConfig || !remoteConfig.baseUrl) {
|
|
46
|
+
throw new Error('Remote server not configured');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const syncConfig = await syncConfigService.getSyncConfig();
|
|
50
|
+
const enabledTypes = (syncConfig.contentTypes || []).filter((ct) => ct.enabled);
|
|
51
|
+
|
|
52
|
+
if (enabledTypes.length === 0) {
|
|
53
|
+
throw new Error('No content types configured for sync');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Pagination — remote + local fetches are chunked to keep memory bounded
|
|
57
|
+
// for large datasets. Page size is a global setting tunable in the Sync tab.
|
|
58
|
+
const globalExec = (await executionService.getGlobalSettings?.()) || {};
|
|
59
|
+
const pageSize = Number(globalExec.syncPageSize) || 100;
|
|
60
|
+
|
|
61
|
+
const timestamps = await getLastSyncTimestamps();
|
|
62
|
+
const conflictStrategy = syncConfig.conflictStrategy || 'latest';
|
|
63
|
+
const results = [];
|
|
64
|
+
|
|
65
|
+
for (const ctConfig of enabledTypes) {
|
|
66
|
+
const { uid, direction, fields } = ctConfig;
|
|
67
|
+
const lastSyncAt = timestamps[uid] || null;
|
|
68
|
+
const syncStartTime = new Date().toISOString();
|
|
69
|
+
|
|
70
|
+
// Get field-level policies from active profile (if any)
|
|
71
|
+
const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// Both sides are fetched in pages of `pageSize` records under the
|
|
75
|
+
// hood (see utils/fetcher.js). We aggregate per content-type because
|
|
76
|
+
// the comparator needs the full set to diff by syncId, but each
|
|
77
|
+
// network/DB call still only returns a bounded chunk.
|
|
78
|
+
const localRecords = await fetchLocalRecords(strapi, uid, { fields, lastSyncAt, pageSize });
|
|
79
|
+
const remoteRecords = await fetchRemoteRecords(remoteConfig, uid, { fields, lastSyncAt, pageSize });
|
|
80
|
+
|
|
81
|
+
const diff = compareRecords(localRecords, remoteRecords, {
|
|
82
|
+
direction,
|
|
83
|
+
conflictStrategy,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
let pushed = 0;
|
|
87
|
+
let pulled = 0;
|
|
88
|
+
let errors = 0;
|
|
89
|
+
|
|
90
|
+
// Apply field policies to records before pushing/pulling
|
|
91
|
+
for (const { local } of diff.toPush) {
|
|
92
|
+
try {
|
|
93
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(local, fieldPolicies, 'push');
|
|
94
|
+
await applyRemote(remoteConfig, uid, filteredRecord, fields);
|
|
95
|
+
pushed++;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
errors++;
|
|
98
|
+
await logService.log({ action: 'push', contentType: uid, syncId: local.syncId, direction: 'push', status: 'error', message: err.message });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const { remote } of diff.toPull) {
|
|
103
|
+
try {
|
|
104
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(remote, fieldPolicies, 'pull');
|
|
105
|
+
await applyLocal(strapi, uid, filteredRecord, fields);
|
|
106
|
+
pulled++;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
errors++;
|
|
109
|
+
await logService.log({ action: 'pull', contentType: uid, syncId: remote.syncId, direction: 'pull', status: 'error', message: err.message });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const record of diff.toCreateRemote) {
|
|
114
|
+
try {
|
|
115
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'push');
|
|
116
|
+
await applyRemote(remoteConfig, uid, filteredRecord, fields);
|
|
117
|
+
pushed++;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
errors++;
|
|
120
|
+
await logService.log({ action: 'create_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const record of diff.toCreateLocal) {
|
|
125
|
+
try {
|
|
126
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'pull');
|
|
127
|
+
await applyLocal(strapi, uid, filteredRecord, fields);
|
|
128
|
+
pulled++;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
errors++;
|
|
131
|
+
await logService.log({ action: 'create_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await setLastSyncTimestamp(uid, syncStartTime);
|
|
136
|
+
|
|
137
|
+
const summary = { uid, pushed, pulled, errors, hasFieldPolicies: !!fieldPolicies };
|
|
138
|
+
results.push(summary);
|
|
139
|
+
|
|
140
|
+
await logService.log({
|
|
141
|
+
action: 'sync_complete',
|
|
142
|
+
contentType: uid,
|
|
143
|
+
direction,
|
|
144
|
+
status: errors > 0 ? 'partial' : 'success',
|
|
145
|
+
message: `Pushed: ${pushed}, Pulled: ${pulled}, Errors: ${errors}${fieldPolicies ? ' (with field policies)' : ''}`,
|
|
146
|
+
details: summary,
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
results.push({ uid, error: err.message });
|
|
150
|
+
await logService.log({
|
|
151
|
+
action: 'sync_error',
|
|
152
|
+
contentType: uid,
|
|
153
|
+
direction,
|
|
154
|
+
status: 'error',
|
|
155
|
+
message: err.message,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { syncedAt: new Date().toISOString(), results };
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Step 8 — Push a single record to the remote (called by lifecycle hooks).
|
|
165
|
+
* Now supports field-level policies.
|
|
166
|
+
*/
|
|
167
|
+
async pushRecord(uid, record) {
|
|
168
|
+
const configService = plugin().service('config');
|
|
169
|
+
const logService = plugin().service('syncLog');
|
|
170
|
+
const syncProfilesService = plugin().service('syncProfiles');
|
|
171
|
+
|
|
172
|
+
const remoteConfig = await configService.getConfig({ safe: false });
|
|
173
|
+
if (!remoteConfig || !remoteConfig.baseUrl) return;
|
|
174
|
+
|
|
175
|
+
const syncConfigService = plugin().service('syncConfig');
|
|
176
|
+
const syncConfig = await syncConfigService.getSyncConfig();
|
|
177
|
+
const ctConfig = (syncConfig.contentTypes || []).find(
|
|
178
|
+
(ct) => ct.uid === uid && ct.enabled
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
if (!ctConfig) return;
|
|
182
|
+
if (ctConfig.direction === 'pull') return;
|
|
183
|
+
|
|
184
|
+
// Get field-level policies from active profile (if any)
|
|
185
|
+
const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
|
|
186
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'push');
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
await applyRemote(remoteConfig, uid, filteredRecord, ctConfig.fields);
|
|
190
|
+
await logService.log({
|
|
191
|
+
action: 'event_push',
|
|
192
|
+
contentType: uid,
|
|
193
|
+
syncId: record.syncId,
|
|
194
|
+
direction: 'push',
|
|
195
|
+
status: 'success',
|
|
196
|
+
message: `Record ${record.syncId} pushed to remote${fieldPolicies ? ' (with field policies)' : ''}`,
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
await logService.log({
|
|
200
|
+
action: 'event_push',
|
|
201
|
+
contentType: uid,
|
|
202
|
+
syncId: record.syncId,
|
|
203
|
+
direction: 'push',
|
|
204
|
+
status: 'error',
|
|
205
|
+
message: err.message,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Step 9 — Receive a record pushed from a remote instance.
|
|
212
|
+
* Now supports field-level policies.
|
|
213
|
+
*/
|
|
214
|
+
async receiveRecord(uid, data, syncId) {
|
|
215
|
+
const logService = plugin().service('syncLog');
|
|
216
|
+
const syncProfilesService = plugin().service('syncProfiles');
|
|
217
|
+
|
|
218
|
+
// Get field-level policies from active profile (if any)
|
|
219
|
+
const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
|
|
220
|
+
const filteredData = syncProfilesService.filterFieldsByPolicy(data, fieldPolicies, 'pull');
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await applyLocal(strapi, uid, { ...filteredData, syncId }, []);
|
|
224
|
+
|
|
225
|
+
await logService.log({
|
|
226
|
+
action: 'receive',
|
|
227
|
+
contentType: uid,
|
|
228
|
+
syncId,
|
|
229
|
+
direction: 'pull',
|
|
230
|
+
status: 'success',
|
|
231
|
+
message: `Record ${syncId} received from remote${fieldPolicies ? ' (with field policies)' : ''}`,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return { success: true };
|
|
235
|
+
} catch (err) {
|
|
236
|
+
await logService.log({
|
|
237
|
+
action: 'receive',
|
|
238
|
+
contentType: uid,
|
|
239
|
+
syncId,
|
|
240
|
+
direction: 'pull',
|
|
241
|
+
status: 'error',
|
|
242
|
+
message: err.message,
|
|
243
|
+
});
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
};
|