strapi-content-sync-pro 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +67 -18
  2. package/admin/src/components/BulkTransferTab.jsx +880 -0
  3. package/admin/src/components/ConfigTab.jsx +25 -4
  4. package/admin/src/components/HelpTab.jsx +201 -15
  5. package/admin/src/components/MediaTab.jsx +7 -0
  6. package/admin/src/components/StatsTab.jsx +470 -0
  7. package/admin/src/components/SyncProfilesTab.jsx +63 -5
  8. package/admin/src/components/SyncTab.jsx +53 -7
  9. package/admin/src/pages/App/index.jsx +15 -1
  10. package/docs/clipchamp-screen-recording-script.md +0 -0
  11. package/docs/production-readiness-status.md +34 -0
  12. package/docs/production-readiness-test-matrix.md +151 -0
  13. package/docs/test-environments-setup-legacy.txt +60 -0
  14. package/package.json +13 -4
  15. package/server/src/content-types/index.js +2 -0
  16. package/server/src/content-types/sync-run-report/schema.json +26 -0
  17. package/server/src/controllers/bulk-transfer.js +141 -0
  18. package/server/src/controllers/config.js +48 -5
  19. package/server/src/controllers/index.js +4 -0
  20. package/server/src/controllers/sync-log.js +6 -0
  21. package/server/src/controllers/sync-media.js +19 -0
  22. package/server/src/controllers/sync-stats.js +51 -0
  23. package/server/src/controllers/sync.js +9 -3
  24. package/server/src/routes/index.js +28 -0
  25. package/server/src/services/bulk-transfer.js +837 -0
  26. package/server/src/services/config.js +18 -2
  27. package/server/src/services/index.js +4 -0
  28. package/server/src/services/sync-execution.js +102 -5
  29. package/server/src/services/sync-log.js +36 -0
  30. package/server/src/services/sync-media.js +224 -1
  31. package/server/src/services/sync-profiles.js +92 -4
  32. package/server/src/services/sync-stats.js +353 -0
  33. package/server/src/services/sync.js +323 -101
  34. package/server/src/utils/applier.js +120 -13
  35. package/server/src/utils/comparator.js +22 -6
  36. package/server/src/utils/fetcher.js +11 -2
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const { fetchLocalRecords, fetchRemoteRecords } = require('../utils/fetcher');
3
+ const { fetchLocalRecords, fetchRemoteRecords, fetchLocalPage, fetchRemotePage } = require('../utils/fetcher');
4
4
  const { compareRecords } = require('../utils/comparator');
5
- const { applyLocal, applyRemote } = require('../utils/applier');
5
+ const { applyLocal, applyRemote, deleteLocal, deleteRemote } = require('../utils/applier');
6
6
 
7
7
  const LAST_SYNC_STORE_KEY = 'last-sync-timestamps';
8
8
 
@@ -49,115 +49,165 @@ module.exports = ({ strapi }) => {
49
49
  const syncConfig = await syncConfigService.getSyncConfig();
50
50
  const enabledTypes = (syncConfig.contentTypes || []).filter((ct) => ct.enabled);
51
51
 
52
- if (enabledTypes.length === 0) {
53
- throw new Error('No content types configured for sync');
54
- }
55
-
56
52
  // Pagination — remote + local fetches are chunked to keep memory bounded
57
53
  // for large datasets. Page size is a global setting tunable in the Sync tab.
58
54
  const globalExec = (await executionService.getGlobalSettings?.()) || {};
59
- const pageSize = Number(globalExec.syncPageSize) || 100;
60
55
 
61
- const timestamps = await getLastSyncTimestamps();
62
- const conflictStrategy = syncConfig.conflictStrategy || 'latest';
63
- const results = [];
56
+ const syncStatsService = plugin().service('syncStats');
57
+ const reportHandle = await syncStatsService.createRunReport({
58
+ runType: 'sync_now',
59
+ trigger: 'manual_sync_now',
60
+ contentTypes: enabledTypes.map((ct) => ct.uid),
61
+ });
64
62
 
65
- for (const ctConfig of enabledTypes) {
66
- const { uid, direction, fields } = ctConfig;
67
- const lastSyncAt = timestamps[uid] || null;
68
- const syncStartTime = new Date().toISOString();
63
+ try {
64
+ if (enabledTypes.length === 0) {
65
+ throw new Error('No content types configured for sync');
66
+ }
69
67
 
70
- // Get field-level policies from active profile (if any)
71
- const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
68
+ const pageSize = Number(globalExec.syncPageSize) || 100;
72
69
 
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
- });
70
+ const timestamps = await getLastSyncTimestamps();
71
+ const conflictStrategy = syncConfig.conflictStrategy || 'latest';
72
+ const results = [];
73
+
74
+ for (const ctConfig of enabledTypes) {
75
+ const { uid, direction, fields } = ctConfig;
76
+ const lastSyncAt = timestamps[uid] || null;
77
+ const syncStartTime = new Date().toISOString();
78
+
79
+ // Get field-level policies from active profile (if any)
80
+ const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
85
81
 
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 });
82
+ try {
83
+ // Both sides are fetched in pages of `pageSize` records under the
84
+ // hood (see utils/fetcher.js). We aggregate per content-type because
85
+ // the comparator needs the full set to diff by syncId, but each
86
+ // network/DB call still only returns a bounded chunk.
87
+ const localRecords = await fetchLocalRecords(strapi, uid, { fields, lastSyncAt, pageSize });
88
+ const remoteRecords = await fetchRemoteRecords(remoteConfig, uid, { fields, lastSyncAt, pageSize });
89
+
90
+ const profileForOptions = await syncProfilesService.getActiveProfileForContentType(uid);
91
+ const syncDeletions = !!(profileForOptions?.syncDeletions);
92
+
93
+ const diff = compareRecords(localRecords, remoteRecords, {
94
+ direction,
95
+ conflictStrategy,
96
+ syncDeletions,
97
+ });
98
+
99
+ let pushed = 0;
100
+ let pulled = 0;
101
+ let errors = 0;
102
+
103
+ // Apply field policies to records before pushing/pulling
104
+ for (const { local } of diff.toPush) {
105
+ try {
106
+ const filteredRecord = syncProfilesService.filterFieldsByPolicy(local, fieldPolicies, 'push');
107
+ await applyRemote(remoteConfig, uid, filteredRecord, fields);
108
+ pushed++;
109
+ } catch (err) {
110
+ errors++;
111
+ await logService.log({ action: 'push', contentType: uid, syncId: local.syncId, direction: 'push', status: 'error', message: err.message });
112
+ }
99
113
  }
100
- }
101
114
 
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 });
115
+ for (const { remote } of diff.toPull) {
116
+ try {
117
+ const filteredRecord = syncProfilesService.filterFieldsByPolicy(remote, fieldPolicies, 'pull');
118
+ await applyLocal(strapi, uid, filteredRecord, fields);
119
+ pulled++;
120
+ } catch (err) {
121
+ errors++;
122
+ await logService.log({ action: 'pull', contentType: uid, syncId: remote.syncId, direction: 'pull', status: 'error', message: err.message });
123
+ }
110
124
  }
111
- }
112
125
 
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 });
126
+ for (const record of diff.toCreateRemote) {
127
+ try {
128
+ const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'push');
129
+ await applyRemote(remoteConfig, uid, filteredRecord, fields);
130
+ pushed++;
131
+ } catch (err) {
132
+ errors++;
133
+ await logService.log({ action: 'create_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
134
+ }
121
135
  }
122
- }
123
136
 
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 });
137
+ for (const record of diff.toCreateLocal) {
138
+ try {
139
+ const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'pull');
140
+ await applyLocal(strapi, uid, filteredRecord, fields);
141
+ pulled++;
142
+ } catch (err) {
143
+ errors++;
144
+ await logService.log({ action: 'create_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
145
+ }
132
146
  }
133
- }
134
147
 
135
- await setLastSyncTimestamp(uid, syncStartTime);
148
+ for (const record of diff.toDeleteRemote) {
149
+ try {
150
+ await deleteRemote(remoteConfig, uid, record);
151
+ await logService.log({ action: 'delete_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'success', message: `Deleted remote record ${record.syncId}` });
152
+ } catch (err) {
153
+ errors++;
154
+ await logService.log({ action: 'delete_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
155
+ }
156
+ }
136
157
 
137
- const summary = { uid, pushed, pulled, errors, hasFieldPolicies: !!fieldPolicies };
138
- results.push(summary);
158
+ for (const record of diff.toDeleteLocal) {
159
+ try {
160
+ await deleteLocal(strapi, uid, record);
161
+ await logService.log({ action: 'delete_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'success', message: `Deleted local record ${record.syncId}` });
162
+ } catch (err) {
163
+ errors++;
164
+ await logService.log({ action: 'delete_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
165
+ }
166
+ }
139
167
 
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
- });
168
+ await setLastSyncTimestamp(uid, syncStartTime);
169
+
170
+ const summary = { uid, pushed, pulled, errors, hasFieldPolicies: !!fieldPolicies };
171
+ results.push(summary);
172
+
173
+ await logService.log({
174
+ action: 'sync_complete',
175
+ contentType: uid,
176
+ direction,
177
+ status: errors > 0 ? 'partial' : 'success',
178
+ message: `Pushed: ${pushed}, Pulled: ${pulled}, Errors: ${errors}${fieldPolicies ? ' (with field policies)' : ''}`,
179
+ details: summary,
180
+ });
181
+ } catch (err) {
182
+ results.push({ uid, error: err.message });
183
+ await logService.log({
184
+ action: 'sync_error',
185
+ contentType: uid,
186
+ direction,
187
+ status: 'error',
188
+ message: err.message,
189
+ });
190
+ }
157
191
  }
158
- }
159
192
 
160
- return { syncedAt: new Date().toISOString(), results };
193
+ const response = { syncedAt: new Date().toISOString(), results };
194
+ await syncStatsService.completeRunReport(reportHandle.reportId, {
195
+ status: 'success',
196
+ summary: response,
197
+ });
198
+ await plugin().service('syncLog').applyRetention({ maxLogs: globalExec.maxLogEntries });
199
+ await syncStatsService.applyRetention({ maxReports: globalExec.maxReportEntries });
200
+ return response;
201
+ } catch (err) {
202
+ await syncStatsService.completeRunReport(reportHandle.reportId, {
203
+ status: 'error',
204
+ summary: null,
205
+ error: err.message,
206
+ });
207
+ await plugin().service('syncLog').applyRetention({ maxLogs: globalExec.maxLogEntries });
208
+ await syncStatsService.applyRetention({ maxReports: globalExec.maxReportEntries });
209
+ throw err;
210
+ }
161
211
  },
162
212
 
163
213
  /**
@@ -191,6 +241,7 @@ module.exports = ({ strapi }) => {
191
241
 
192
242
  const direction = profile?.direction || ctConfig.direction || 'both';
193
243
  const conflictStrategy = profile?.conflictStrategy || syncConfig.conflictStrategy || 'latest';
244
+ const syncDeletions = !!(profile?.syncDeletions);
194
245
  const fields = ctConfig.fields || [];
195
246
 
196
247
  // Field-level policies: prefer the policies on the provided profile,
@@ -222,7 +273,7 @@ module.exports = ({ strapi }) => {
222
273
  const localRecords = await fetchLocalRecords(strapi, uid, { fields, lastSyncAt, pageSize });
223
274
  const remoteRecords = await fetchRemoteRecords(remoteConfig, uid, { fields, lastSyncAt, pageSize });
224
275
 
225
- const diff = compareRecords(localRecords, remoteRecords, { direction, conflictStrategy });
276
+ const diff = compareRecords(localRecords, remoteRecords, { direction, conflictStrategy, syncDeletions });
226
277
 
227
278
  for (const { local } of diff.toPush) {
228
279
  try {
@@ -268,6 +319,26 @@ module.exports = ({ strapi }) => {
268
319
  }
269
320
  }
270
321
 
322
+ for (const record of diff.toDeleteRemote) {
323
+ try {
324
+ await deleteRemote(remoteConfig, uid, record);
325
+ await logService.log({ action: 'delete_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'success', message: `Deleted remote record ${record.syncId}` });
326
+ } catch (err) {
327
+ errors++;
328
+ await logService.log({ action: 'delete_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
329
+ }
330
+ }
331
+
332
+ for (const record of diff.toDeleteLocal) {
333
+ try {
334
+ await deleteLocal(strapi, uid, record);
335
+ await logService.log({ action: 'delete_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'success', message: `Deleted local record ${record.syncId}` });
336
+ } catch (err) {
337
+ errors++;
338
+ await logService.log({ action: 'delete_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
339
+ }
340
+ }
341
+
271
342
  await setLastSyncTimestamp(uid, syncStartTime);
272
343
 
273
344
  const summary = {
@@ -301,6 +372,142 @@ module.exports = ({ strapi }) => {
301
372
  }
302
373
  },
303
374
 
375
+ /**
376
+ * Sync a SINGLE PAGE of a content type. Used by the bulk-transfer (Full
377
+ * Sync) engine to process large content types page-by-page so that
378
+ * progress can be reported and the job can be paused / resumed between
379
+ * pages.
380
+ *
381
+ * options:
382
+ * - profile: synthetic/real profile (direction, conflictStrategy, syncDeletions)
383
+ * - page: 1-based page number (default 1)
384
+ * - pageSize: records per page (default from global settings or 100)
385
+ * - lastSyncAt: optional ISO timestamp; when omitted this runs a full
386
+ * page scan (preferred for bulk transfer). When provided it acts
387
+ * incremental.
388
+ *
389
+ * Returns:
390
+ * { uid, page, pageSize, pushed, pulled, errors, hasMore,
391
+ * localCount, remoteCount, remoteTotal, remotePageCount }
392
+ */
393
+ async syncContentTypePage(uid, options = {}) {
394
+ if (!uid) throw new Error('Content type uid is required');
395
+
396
+ const logService = plugin().service('syncLog');
397
+ const configService = plugin().service('config');
398
+ const syncConfigService = plugin().service('syncConfig');
399
+ const syncProfilesService = plugin().service('syncProfiles');
400
+ const executionService = plugin().service('syncExecution');
401
+
402
+ const remoteConfig = await configService.getConfig({ safe: false });
403
+ if (!remoteConfig || !remoteConfig.baseUrl) {
404
+ throw new Error('Remote server not configured');
405
+ }
406
+
407
+ const { profile } = options;
408
+ const syncConfig = await syncConfigService.getSyncConfig();
409
+ const ctConfig = (syncConfig.contentTypes || []).find((ct) => ct.uid === uid) || { uid, fields: [] };
410
+
411
+ const direction = profile?.direction || ctConfig.direction || 'both';
412
+ const conflictStrategy = profile?.conflictStrategy || syncConfig.conflictStrategy || 'latest';
413
+ const syncDeletions = !!(profile?.syncDeletions);
414
+ const fields = ctConfig.fields || [];
415
+
416
+ let fieldPolicies = null;
417
+ if (profile && !profile.isSimple && Array.isArray(profile.fieldPolicies) && profile.fieldPolicies.length > 0) {
418
+ fieldPolicies = {};
419
+ for (const fp of profile.fieldPolicies) fieldPolicies[fp.field] = fp.direction;
420
+ } else if (!profile) {
421
+ fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
422
+ }
423
+
424
+ const globalExec = (await executionService.getGlobalSettings?.()) || {};
425
+ const pageSize = Number(options.pageSize) || Number(globalExec.syncPageSize) || 100;
426
+ const page = Math.max(1, Number(options.page) || 1);
427
+ const lastSyncAt = options.lastSyncAt || null;
428
+
429
+ let pushed = 0;
430
+ let pulled = 0;
431
+ let errors = 0;
432
+
433
+ const localPageRes = await fetchLocalPage(strapi, uid, { fields, lastSyncAt, page, pageSize });
434
+ const remotePageRes = await fetchRemotePage(remoteConfig, uid, { fields, lastSyncAt, page, pageSize });
435
+
436
+ const localRecords = localPageRes.records || [];
437
+ const remoteRecords = remotePageRes.records || [];
438
+
439
+ // NOTE: comparator works on the page slice only. Cross-side deletion
440
+ // detection is intentionally disabled here because a record missing
441
+ // from this page may live on another page; full-set deletion sync
442
+ // should use the incremental path instead.
443
+ const diff = compareRecords(localRecords, remoteRecords, {
444
+ direction,
445
+ conflictStrategy,
446
+ syncDeletions: false,
447
+ });
448
+
449
+ for (const { local } of diff.toPush) {
450
+ try {
451
+ const filtered = syncProfilesService.filterFieldsByPolicy(local, fieldPolicies, 'push');
452
+ await applyRemote(remoteConfig, uid, filtered, fields);
453
+ pushed++;
454
+ } catch (err) {
455
+ errors++;
456
+ await logService.log({ action: 'push', contentType: uid, syncId: local.syncId, direction: 'push', status: 'error', message: err.message });
457
+ }
458
+ }
459
+
460
+ for (const { remote } of diff.toPull) {
461
+ try {
462
+ const filtered = syncProfilesService.filterFieldsByPolicy(remote, fieldPolicies, 'pull');
463
+ await applyLocal(strapi, uid, filtered, fields);
464
+ pulled++;
465
+ } catch (err) {
466
+ errors++;
467
+ await logService.log({ action: 'pull', contentType: uid, syncId: remote.syncId, direction: 'pull', status: 'error', message: err.message });
468
+ }
469
+ }
470
+
471
+ for (const record of diff.toCreateRemote) {
472
+ try {
473
+ const filtered = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'push');
474
+ await applyRemote(remoteConfig, uid, filtered, fields);
475
+ pushed++;
476
+ } catch (err) {
477
+ errors++;
478
+ await logService.log({ action: 'create_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
479
+ }
480
+ }
481
+
482
+ for (const record of diff.toCreateLocal) {
483
+ try {
484
+ const filtered = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'pull');
485
+ await applyLocal(strapi, uid, filtered, fields);
486
+ pulled++;
487
+ } catch (err) {
488
+ errors++;
489
+ await logService.log({ action: 'create_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
490
+ }
491
+ }
492
+
493
+ // hasMore is the OR of both sides so we keep paging until both are drained
494
+ const hasMore = !!(localPageRes.hasMore || remotePageRes.hasMore);
495
+
496
+ return {
497
+ uid,
498
+ page,
499
+ pageSize,
500
+ pushed,
501
+ pulled,
502
+ errors,
503
+ hasMore,
504
+ localCount: localRecords.length,
505
+ remoteCount: remoteRecords.length,
506
+ remoteTotal: remotePageRes.total,
507
+ remotePageCount: remotePageRes.pageCount,
508
+ };
509
+ },
510
+
304
511
  /**
305
512
  * Step 8 — Push a single record to the remote (called by lifecycle hooks).
306
513
  * Now supports field-level policies.
@@ -352,32 +559,47 @@ module.exports = ({ strapi }) => {
352
559
  * Step 9 — Receive a record pushed from a remote instance.
353
560
  * Now supports field-level policies.
354
561
  */
355
- async receiveRecord(uid, data, syncId) {
562
+ async receiveRecord(uid, data, syncId, isDelete = false, documentId = null) {
356
563
  const logService = plugin().service('syncLog');
357
564
  const syncProfilesService = plugin().service('syncProfiles');
358
565
 
359
- // Get field-level policies from active profile (if any)
360
- const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
361
- const filteredData = syncProfilesService.filterFieldsByPolicy(data, fieldPolicies, 'pull');
566
+ const key = documentId || syncId;
362
567
 
363
568
  try {
364
- await applyLocal(strapi, uid, { ...filteredData, syncId }, []);
569
+ if (isDelete) {
570
+ await deleteLocal(strapi, uid, { documentId, syncId });
571
+ await logService.log({
572
+ action: 'receive_delete',
573
+ contentType: uid,
574
+ syncId: key,
575
+ direction: 'pull',
576
+ status: 'success',
577
+ message: `Delete received for ${key} from remote`,
578
+ });
579
+ return { success: true, deleted: true };
580
+ }
581
+
582
+ // Get field-level policies from active profile (if any)
583
+ const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
584
+ const filteredData = syncProfilesService.filterFieldsByPolicy(data, fieldPolicies, 'pull');
585
+
586
+ await applyLocal(strapi, uid, { ...filteredData, documentId, syncId }, []);
365
587
 
366
588
  await logService.log({
367
589
  action: 'receive',
368
590
  contentType: uid,
369
- syncId,
591
+ syncId: key,
370
592
  direction: 'pull',
371
593
  status: 'success',
372
- message: `Record ${syncId} received from remote${fieldPolicies ? ' (with field policies)' : ''}`,
594
+ message: `Record ${key} received from remote${fieldPolicies ? ' (with field policies)' : ''}`,
373
595
  });
374
596
 
375
597
  return { success: true };
376
598
  } catch (err) {
377
599
  await logService.log({
378
- action: 'receive',
600
+ action: isDelete ? 'receive_delete' : 'receive',
379
601
  contentType: uid,
380
- syncId,
602
+ syncId: key,
381
603
  direction: 'pull',
382
604
  status: 'error',
383
605
  message: err.message,