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.
- package/README.md +33 -14
- package/admin/src/components/BulkTransferTab.jsx +880 -0
- package/admin/src/components/ConfigTab.jsx +81 -3
- package/admin/src/components/HelpTab.jsx +148 -5
- package/admin/src/components/MediaTab.jsx +141 -30
- package/admin/src/components/SyncTab.jsx +2 -0
- package/admin/src/pages/App/index.jsx +12 -1
- package/docs/Screenshot 2026-04-22 183540.png +0 -0
- package/docs/Screenshot 2026-04-22 183552.png +0 -0
- package/docs/Screenshot 2026-04-23 114332.png +0 -0
- package/docs/Screenshot 2026-04-23 114644.png +0 -0
- package/docs/Screenshot 2026-04-23 114651.png +0 -0
- package/docs/Screenshot 2026-04-23 114737.png +0 -0
- package/docs/Screenshot 2026-04-23 114904.png +0 -0
- package/docs/Screenshot 2026-04-23 114940.png +0 -0
- package/docs/Screenshot 2026-04-23 115003.png +0 -0
- package/docs/Screenshot 2026-04-23 115024.png +0 -0
- package/docs/Screenshot 2026-04-23 115116.png +0 -0
- package/docs/Screenshot 2026-04-23 115141.png +0 -0
- package/docs/Screenshot 2026-04-23 115252.png +0 -0
- package/docs/Screenshot 2026-04-23 115448.png +0 -0
- package/docs/Screenshot 2026-04-23 120534.png +0 -0
- package/docs/Screenshot 2026-04-23 122544.png +0 -0
- package/docs/Screenshot 2026-04-23 122712.png +0 -0
- package/docs/Screenshot 2026-04-23 122730.png +0 -0
- package/docs/Screenshot 2026-04-23 122858.png +0 -0
- package/docs/Screenshot 2026-04-23 122924.png +0 -0
- package/docs/Screenshot 2026-04-23 122937.png +0 -0
- package/package.json +13 -4
- package/server/src/controllers/bulk-transfer.js +141 -0
- package/server/src/controllers/config.js +76 -3
- package/server/src/controllers/index.js +2 -0
- package/server/src/controllers/sync-media.js +24 -0
- package/server/src/routes/index.js +18 -0
- package/server/src/services/bulk-transfer.js +837 -0
- package/server/src/services/index.js +2 -0
- package/server/src/services/sync-media.js +168 -32
- package/server/src/services/sync.js +137 -1
- package/docs/Screenshot 2026-04-20 160506.png +0 -0
- package/docs/Screenshot 2026-04-20 160558.png +0 -0
- package/docs/Screenshot 2026-04-20 175903.png +0 -0
- package/docs/Screenshot 2026-04-20 175931.png +0 -0
- package/docs/Screenshot 2026-04-20 180001.png +0 -0
- package/docs/Screenshot 2026-04-20 180041.png +0 -0
- package/docs/Screenshot 2026-04-20 180116.png +0 -0
- package/docs/Screenshot 2026-04-20 180135.png +0 -0
- package/docs/Screenshot 2026-04-20 180202.png +0 -0
- package/docs/Screenshot 2026-04-20 180228.png +0 -0
- package/docs/Screenshot 2026-04-20 180251.png +0 -0
- package/docs/Screenshot 2026-04-20 180301.png +0 -0
- package/docs/clipchamp-screen-recording-script.md +0 -0
- package/docs/production-readiness-status.md +0 -34
- package/docs/production-readiness-test-matrix.md +0 -151
- 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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
const
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
if (
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
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.
|