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,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
+ };