strapi-content-sync-pro 1.0.3 → 1.0.5

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 (54) hide show
  1. package/README.md +33 -14
  2. package/admin/src/components/BulkTransferTab.jsx +880 -0
  3. package/admin/src/components/ConfigTab.jsx +81 -3
  4. package/admin/src/components/HelpTab.jsx +148 -5
  5. package/admin/src/components/MediaTab.jsx +141 -30
  6. package/admin/src/components/SyncTab.jsx +2 -0
  7. package/admin/src/pages/App/index.jsx +12 -1
  8. package/docs/Screenshot 2026-04-22 183540.png +0 -0
  9. package/docs/Screenshot 2026-04-22 183552.png +0 -0
  10. package/docs/Screenshot 2026-04-23 114332.png +0 -0
  11. package/docs/Screenshot 2026-04-23 114644.png +0 -0
  12. package/docs/Screenshot 2026-04-23 114651.png +0 -0
  13. package/docs/Screenshot 2026-04-23 114737.png +0 -0
  14. package/docs/Screenshot 2026-04-23 114904.png +0 -0
  15. package/docs/Screenshot 2026-04-23 114940.png +0 -0
  16. package/docs/Screenshot 2026-04-23 115003.png +0 -0
  17. package/docs/Screenshot 2026-04-23 115024.png +0 -0
  18. package/docs/Screenshot 2026-04-23 115116.png +0 -0
  19. package/docs/Screenshot 2026-04-23 115141.png +0 -0
  20. package/docs/Screenshot 2026-04-23 115252.png +0 -0
  21. package/docs/Screenshot 2026-04-23 115448.png +0 -0
  22. package/docs/Screenshot 2026-04-23 120534.png +0 -0
  23. package/docs/Screenshot 2026-04-23 122544.png +0 -0
  24. package/docs/Screenshot 2026-04-23 122712.png +0 -0
  25. package/docs/Screenshot 2026-04-23 122730.png +0 -0
  26. package/docs/Screenshot 2026-04-23 122858.png +0 -0
  27. package/docs/Screenshot 2026-04-23 122924.png +0 -0
  28. package/docs/Screenshot 2026-04-23 122937.png +0 -0
  29. package/package.json +13 -4
  30. package/server/src/controllers/bulk-transfer.js +141 -0
  31. package/server/src/controllers/config.js +76 -3
  32. package/server/src/controllers/index.js +2 -0
  33. package/server/src/controllers/sync-media.js +24 -0
  34. package/server/src/routes/index.js +18 -0
  35. package/server/src/services/bulk-transfer.js +837 -0
  36. package/server/src/services/index.js +2 -0
  37. package/server/src/services/sync-media.js +168 -32
  38. package/server/src/services/sync.js +137 -1
  39. package/docs/Screenshot 2026-04-20 160506.png +0 -0
  40. package/docs/Screenshot 2026-04-20 160558.png +0 -0
  41. package/docs/Screenshot 2026-04-20 175903.png +0 -0
  42. package/docs/Screenshot 2026-04-20 175931.png +0 -0
  43. package/docs/Screenshot 2026-04-20 180001.png +0 -0
  44. package/docs/Screenshot 2026-04-20 180041.png +0 -0
  45. package/docs/Screenshot 2026-04-20 180116.png +0 -0
  46. package/docs/Screenshot 2026-04-20 180135.png +0 -0
  47. package/docs/Screenshot 2026-04-20 180202.png +0 -0
  48. package/docs/Screenshot 2026-04-20 180228.png +0 -0
  49. package/docs/Screenshot 2026-04-20 180251.png +0 -0
  50. package/docs/Screenshot 2026-04-20 180301.png +0 -0
  51. package/docs/clipchamp-screen-recording-script.md +0 -0
  52. package/docs/production-readiness-status.md +0 -34
  53. package/docs/production-readiness-test-matrix.md +0 -151
  54. package/docs/test-environments-setup-legacy.txt +0 -60
@@ -13,6 +13,7 @@ const syncEnforcement = require('./sync-enforcement');
13
13
  const syncMedia = require('./sync-media');
14
14
  const alerts = require('./alerts');
15
15
  const syncStats = require('./sync-stats');
16
+ const bulkTransfer = require('./bulk-transfer');
16
17
 
17
18
  module.exports = {
18
19
  ping,
@@ -28,5 +29,6 @@ module.exports = {
28
29
  syncMedia,
29
30
  alerts,
30
31
  syncStats,
32
+ bulkTransfer,
31
33
  };
32
34
 
@@ -147,6 +147,64 @@ module.exports = ({ strapi }) => {
147
147
  const log = strapi.log;
148
148
  const schedulerHandles = {};
149
149
 
150
+ // ── In-memory run controls (pause/resume/cancel) per profile ─────────────
151
+ // Key: profileId, Value: { signal: 'run' | 'pause' | 'cancel',
152
+ // resumeDeferred: { promise, resolve } | null,
153
+ // progress: { phase, processed, total, pushed, pulled, skipped, errors } }
154
+ const runControls = {};
155
+
156
+ function getControl(profileId) {
157
+ if (!runControls[profileId]) {
158
+ runControls[profileId] = {
159
+ signal: 'run',
160
+ resumeDeferred: null,
161
+ progress: { phase: 'idle', processed: 0, total: 0, pushed: 0, pulled: 0, skipped: 0, errors: 0 },
162
+ };
163
+ }
164
+ return runControls[profileId];
165
+ }
166
+
167
+ function makeDeferred() {
168
+ let resolve;
169
+ const promise = new Promise((r) => { resolve = r; });
170
+ return { promise, resolve };
171
+ }
172
+
173
+ // Cooperative checkpoint — call between units of work. Returns:
174
+ // { cancelled: true } if the run should abort, or null to continue.
175
+ async function checkpoint(profileId) {
176
+ const c = getControl(profileId);
177
+ if (c.signal === 'cancel') return { cancelled: true };
178
+ if (c.signal === 'pause') {
179
+ if (!c.resumeDeferred) c.resumeDeferred = makeDeferred();
180
+ // Mirror pause state into persisted status so UI sees it
181
+ try {
182
+ const s = await store().get({ key: STATUS_KEY }) || {};
183
+ s.paused = { ...(s.paused || {}), [profileId]: true };
184
+ await store().set({ key: STATUS_KEY, value: s });
185
+ } catch { /* ignore */ }
186
+ await c.resumeDeferred.promise;
187
+ c.resumeDeferred = null;
188
+ try {
189
+ const s = await store().get({ key: STATUS_KEY }) || {};
190
+ if (s.paused) { delete s.paused[profileId]; }
191
+ await store().set({ key: STATUS_KEY, value: s });
192
+ } catch { /* ignore */ }
193
+ if (c.signal === 'cancel') return { cancelled: true };
194
+ }
195
+ return null;
196
+ }
197
+
198
+ async function updateProgress(profileId, patch) {
199
+ const c = getControl(profileId);
200
+ c.progress = { ...c.progress, ...patch };
201
+ try {
202
+ const s = await store().get({ key: STATUS_KEY }) || {};
203
+ s.progress = { ...(s.progress || {}), [profileId]: { ...c.progress } };
204
+ await store().set({ key: STATUS_KEY, value: s });
205
+ } catch { /* ignore */ }
206
+ }
207
+
150
208
  function store() {
151
209
  return strapi.store({ type: 'plugin', name: PLUGIN_NAME });
152
210
  }
@@ -293,6 +351,9 @@ module.exports = ({ strapi }) => {
293
351
  lastExecutedAt: p.lastExecutedAt,
294
352
  nextExecutionAt: p.nextExecutionAt,
295
353
  running: !!(s && s.runningProfiles && s.runningProfiles[p.id]),
354
+ paused: !!(s && s.paused && s.paused[p.id]),
355
+ signal: runControls[p.id]?.signal || 'idle',
356
+ progress: (s && s.progress && s.progress[p.id]) || null,
296
357
  })),
297
358
  lastRunAt: s?.lastRunAt || null,
298
359
  lastResult: s?.lastResult || null,
@@ -755,13 +816,18 @@ module.exports = ({ strapi }) => {
755
816
 
756
817
  /**
757
818
  * Copy a page of files respecting settings.batchConcurrency.
819
+ * If checkAbort() returns { cancelled: true }, remaining items are skipped.
758
820
  */
759
- async function processBatch(items, worker, concurrency) {
760
- const out = { success: 0, skipped: 0, errors: [] };
821
+ async function processBatch(items, worker, concurrency, checkAbort) {
822
+ const out = { success: 0, skipped: 0, errors: [], cancelled: false };
761
823
  const c = Math.max(1, Math.min(concurrency || 1, 10));
762
824
  let i = 0;
763
825
  async function run() {
764
826
  while (i < items.length) {
827
+ if (checkAbort) {
828
+ const abort = await checkAbort();
829
+ if (abort && abort.cancelled) { out.cancelled = true; return; }
830
+ }
765
831
  const idx = i++;
766
832
  const item = items[idx];
767
833
  try {
@@ -785,15 +851,24 @@ module.exports = ({ strapi }) => {
785
851
 
786
852
  const totals = { pushed: 0, pulled: 0, skipped: 0, dbRowsUpdated: 0, morphLinksApplied: 0, morphLinksSkipped: 0, errors: [] };
787
853
  const started = Date.now();
854
+ const pid = profile.id;
855
+ const abort = () => checkpoint(pid);
856
+
857
+ await updateProgress(pid, { phase: 'indexing-local', processed: 0, total: 0, pushed: 0, pulled: 0, skipped: 0, errors: 0 });
788
858
 
789
859
  const localIndex = new Map();
790
860
  for await (const batch of iterateLocalFiles(settings.pageSize)) {
861
+ const a = await checkpoint(pid);
862
+ if (a && a.cancelled) { totals.cancelled = true; break; }
791
863
  for (const f of batch) localIndex.set(`${f.hash}|${f.name}`, f);
792
864
  }
793
865
 
794
866
  // PULL: remote -> local
795
- if (settings.direction === 'pull' || settings.direction === 'both') {
867
+ if (!totals.cancelled && (settings.direction === 'pull' || settings.direction === 'both')) {
868
+ await updateProgress(pid, { phase: 'pull' });
796
869
  for await (const remoteBatch of iterateRemoteFiles(remoteConfig, settings.pageSize)) {
870
+ const a = await checkpoint(pid);
871
+ if (a && a.cancelled) { totals.cancelled = true; break; }
797
872
  const filtered = remoteBatch.filter((f) => passesFilters(f, profile));
798
873
  const result = await processBatch(filtered, async (rf) => {
799
874
  const key = `${rf.hash}|${rf.name}`;
@@ -813,16 +888,24 @@ module.exports = ({ strapi }) => {
813
888
  await uploadBufferToLocal(rf, buf);
814
889
  }
815
890
  return 'success';
816
- }, settings.batchConcurrency);
891
+ }, settings.batchConcurrency, abort);
817
892
  totals.pulled += result.success;
818
893
  totals.skipped += result.skipped;
819
894
  totals.errors.push(...result.errors);
895
+ await updateProgress(pid, {
896
+ pulled: totals.pulled,
897
+ skipped: totals.skipped,
898
+ errors: totals.errors.length,
899
+ processed: totals.pulled + totals.pushed + totals.skipped,
900
+ });
901
+ if (result.cancelled) { totals.cancelled = true; break; }
820
902
  }
821
903
  }
822
904
 
823
- if (profile.syncDbRows) {
905
+ if (!totals.cancelled && profile.syncDbRows) {
824
906
  try {
825
907
  if (settings.direction === 'pull' || settings.direction === 'both') {
908
+ await updateProgress(pid, { phase: 'pull-morph' });
826
909
  const remoteLinks = await fetchRemoteMorphLinks(remoteConfig);
827
910
  const applyResult = await applyMorphLinks(remoteLinks);
828
911
  totals.morphLinksApplied += applyResult.applied || 0;
@@ -837,42 +920,58 @@ module.exports = ({ strapi }) => {
837
920
  }
838
921
 
839
922
  // PUSH: local -> remote
840
- if (settings.direction === 'push' || settings.direction === 'both') {
923
+ if (!totals.cancelled && (settings.direction === 'push' || settings.direction === 'both')) {
924
+ await updateProgress(pid, { phase: 'push-indexing-remote' });
841
925
  const remoteIndex = new Map();
842
926
  for await (const remoteBatch of iterateRemoteFiles(remoteConfig, settings.pageSize)) {
927
+ const a = await checkpoint(pid);
928
+ if (a && a.cancelled) { totals.cancelled = true; break; }
843
929
  for (const f of remoteBatch) remoteIndex.set(`${f.hash}|${f.name}`, f);
844
930
  }
845
931
 
846
- for await (const localBatch of iterateLocalFiles(settings.pageSize)) {
847
- const filtered = localBatch.filter((f) => passesFilters(f, profile));
848
- const result = await processBatch(filtered, async (lf) => {
849
- const key = `${lf.hash}|${lf.name}`;
850
- const rf = remoteIndex.get(key);
851
-
852
- // DB-row sync (push metadata)
853
- if (profile.syncDbRows && rf) {
854
- const dbResult = await syncDbRowPush(lf, rf, profile, remoteConfig);
855
- if (dbResult === 'updated') totals.dbRowsUpdated++;
856
- }
857
-
858
- // File-byte sync
859
- if (profile.syncFileBytes) {
860
- if (shouldSkip(lf, rf, settings)) return 'skipped';
861
- if (settings.dryRun) return 'success';
862
- const buf = await readLocalFileBuffer(lf);
863
- if (!buf) return 'skipped';
864
- await uploadBufferToRemote(remoteConfig, lf, buf);
865
- }
866
- return 'success';
867
- }, settings.batchConcurrency);
868
- totals.pushed += result.success;
869
- totals.skipped += result.skipped;
870
- totals.errors.push(...result.errors);
932
+ if (!totals.cancelled) {
933
+ await updateProgress(pid, { phase: 'push' });
934
+ for await (const localBatch of iterateLocalFiles(settings.pageSize)) {
935
+ const a = await checkpoint(pid);
936
+ if (a && a.cancelled) { totals.cancelled = true; break; }
937
+ const filtered = localBatch.filter((f) => passesFilters(f, profile));
938
+ const result = await processBatch(filtered, async (lf) => {
939
+ const key = `${lf.hash}|${lf.name}`;
940
+ const rf = remoteIndex.get(key);
941
+
942
+ // DB-row sync (push metadata)
943
+ if (profile.syncDbRows && rf) {
944
+ const dbResult = await syncDbRowPush(lf, rf, profile, remoteConfig);
945
+ if (dbResult === 'updated') totals.dbRowsUpdated++;
946
+ }
947
+
948
+ // File-byte sync
949
+ if (profile.syncFileBytes) {
950
+ if (shouldSkip(lf, rf, settings)) return 'skipped';
951
+ if (settings.dryRun) return 'success';
952
+ const buf = await readLocalFileBuffer(lf);
953
+ if (!buf) return 'skipped';
954
+ await uploadBufferToRemote(remoteConfig, lf, buf);
955
+ }
956
+ return 'success';
957
+ }, settings.batchConcurrency, abort);
958
+ totals.pushed += result.success;
959
+ totals.skipped += result.skipped;
960
+ totals.errors.push(...result.errors);
961
+ await updateProgress(pid, {
962
+ pushed: totals.pushed,
963
+ skipped: totals.skipped,
964
+ errors: totals.errors.length,
965
+ processed: totals.pulled + totals.pushed + totals.skipped,
966
+ });
967
+ if (result.cancelled) { totals.cancelled = true; break; }
968
+ }
871
969
  }
872
970
  }
873
971
 
874
- if (profile.syncDbRows && (settings.direction === 'push' || settings.direction === 'both')) {
972
+ if (!totals.cancelled && profile.syncDbRows && (settings.direction === 'push' || settings.direction === 'both')) {
875
973
  try {
974
+ await updateProgress(pid, { phase: 'push-morph' });
876
975
  const localLinks = await exportMorphLinks();
877
976
  const applyRemoteResult = await applyRemoteMorphLinks(remoteConfig, localLinks);
878
977
  totals.morphLinksApplied += applyRemoteResult.applied || 0;
@@ -885,6 +984,8 @@ module.exports = ({ strapi }) => {
885
984
  }
886
985
  }
887
986
 
987
+ await updateProgress(pid, { phase: totals.cancelled ? 'cancelled' : 'done' });
988
+
888
989
  const summary = {
889
990
  strategy: 'url',
890
991
  profileId: profile.id,
@@ -1041,9 +1142,17 @@ module.exports = ({ strapi }) => {
1041
1142
  const globalSettings = await getGlobalSettings();
1042
1143
  const merged = { ...globalSettings, ...profile, ...options };
1043
1144
 
1145
+ // Reset run controls for a fresh execution
1146
+ const control = getControl(profileId);
1147
+ control.signal = 'run';
1148
+ control.resumeDeferred = null;
1149
+ control.progress = { phase: 'starting', processed: 0, total: 0, pushed: 0, pulled: 0, skipped: 0, errors: 0 };
1150
+
1044
1151
  const statusData = await store().get({ key: STATUS_KEY }) || {};
1045
1152
  statusData.running = true;
1046
1153
  statusData.runningProfiles = { ...(statusData.runningProfiles || {}), [profileId]: true };
1154
+ statusData.progress = { ...(statusData.progress || {}), [profileId]: { ...control.progress } };
1155
+ if (statusData.paused) delete statusData.paused[profileId];
1047
1156
  await setStatus(statusData);
1048
1157
 
1049
1158
  try {
@@ -1059,6 +1168,7 @@ module.exports = ({ strapi }) => {
1059
1168
 
1060
1169
  const s2 = await store().get({ key: STATUS_KEY }) || {};
1061
1170
  delete (s2.runningProfiles || {})[profileId];
1171
+ if (s2.paused) delete s2.paused[profileId];
1062
1172
  s2.running = Object.keys(s2.runningProfiles || {}).length > 0;
1063
1173
  s2.lastRunAt = new Date().toISOString();
1064
1174
  s2.lastResult = result;
@@ -1068,6 +1178,7 @@ module.exports = ({ strapi }) => {
1068
1178
  } catch (err) {
1069
1179
  const s2 = await store().get({ key: STATUS_KEY }) || {};
1070
1180
  delete (s2.runningProfiles || {})[profileId];
1181
+ if (s2.paused) delete s2.paused[profileId];
1071
1182
  s2.running = Object.keys(s2.runningProfiles || {}).length > 0;
1072
1183
  s2.lastRunAt = new Date().toISOString();
1073
1184
  s2.lastResult = { error: err.message };
@@ -1076,6 +1187,28 @@ module.exports = ({ strapi }) => {
1076
1187
  }
1077
1188
  }
1078
1189
 
1190
+ async function pauseProfile(profileId) {
1191
+ const c = getControl(profileId);
1192
+ if (c.signal === 'cancel') return { ok: false, message: 'Run is cancelling' };
1193
+ c.signal = 'pause';
1194
+ return { ok: true, signal: c.signal };
1195
+ }
1196
+
1197
+ async function resumeProfile(profileId) {
1198
+ const c = getControl(profileId);
1199
+ if (c.signal !== 'pause') return { ok: false, message: `Run is not paused (signal=${c.signal})` };
1200
+ c.signal = 'run';
1201
+ if (c.resumeDeferred) { c.resumeDeferred.resolve(); }
1202
+ return { ok: true, signal: c.signal };
1203
+ }
1204
+
1205
+ async function cancelProfile(profileId) {
1206
+ const c = getControl(profileId);
1207
+ c.signal = 'cancel';
1208
+ if (c.resumeDeferred) { c.resumeDeferred.resolve(); } // unblock any pause wait
1209
+ return { ok: true, signal: c.signal };
1210
+ }
1211
+
1079
1212
  async function runActiveProfiles() {
1080
1213
  const profiles = await getProfiles();
1081
1214
  const active = profiles.filter((p) => p.active && p.strategy !== 'disabled');
@@ -1118,6 +1251,9 @@ module.exports = ({ strapi }) => {
1118
1251
  // Execution
1119
1252
  runProfile,
1120
1253
  runActiveProfiles,
1254
+ pauseProfile,
1255
+ resumeProfile,
1256
+ cancelProfile,
1121
1257
 
1122
1258
  // Morph link APIs (documentId-based)
1123
1259
  exportMorphLinks,
@@ -1,6 +1,6 @@
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
5
  const { applyLocal, applyRemote, deleteLocal, deleteRemote } = require('../utils/applier');
6
6
 
@@ -372,6 +372,142 @@ module.exports = ({ strapi }) => {
372
372
  }
373
373
  },
374
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
+
375
511
  /**
376
512
  * Step 8 — Push a single record to the remote (called by lifecycle hooks).
377
513
  * Now supports field-level policies.
File without changes
@@ -1,34 +0,0 @@
1
- # Production Readiness Status — Content Sync Pro
2
-
3
- ## Current Verdict
4
- **NO-GO (not yet fully production-ready)**
5
-
6
- ## Completed
7
- - Implemented paired/single-side mode behavior and enforcement.
8
- - Implemented Stats tab + before/after run reports.
9
- - Implemented manual clear + retention limits for logs/reports.
10
- - Added production-readiness test matrix:
11
- - `docs/production-readiness-test-matrix.md`
12
- - Added legacy environment notes copy:
13
- - `docs/test-environments-setup-legacy.txt`
14
-
15
- ## Smoke Checks Passed
16
- - `GET http://localhost:40101/api/strapi-content-sync-pro/ping` => 200
17
- - `GET http://localhost:4010/api/strapi-content-sync-pro/ping` => 200
18
- - Package test script passes (`npm run test`) — placeholder only.
19
-
20
- ## Blocking Items Before GO
21
- 1. Execute full P0 and P1 matrix scenarios in `docs/production-readiness-test-matrix.md`.
22
- 2. Capture evidence for each case (request/response, DB/file verification, screenshots).
23
- 3. Verify restart/recovery after plugin copy in target runtime path.
24
- 4. Validate single-side mode with remote plugin disabled.
25
- 5. Validate media restore scenarios after partial deletions.
26
- 6. Confirm retention pruning under load (high log/report volume).
27
-
28
- ## Required Release Gate
29
- - P0 cases: 100% pass
30
- - P1 cases: pass or accepted risk signed off
31
- - No open critical defects
32
-
33
- ## Recommended Next Action
34
- Run matrix execution in order: P0 -> P1 -> P2, then update this file with final **GO/NO-GO** sign-off.