strapi-content-sync-pro 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +32 -14
- package/admin/src/components/BulkTransferTab.jsx +185 -20
- package/admin/src/components/ConfigTab.jsx +81 -3
- package/admin/src/components/ContentTypesTab.jsx +28 -1
- package/admin/src/components/HelpTab.jsx +34 -0
- package/admin/src/components/LogsTab.jsx +66 -8
- package/admin/src/components/MediaTab.jsx +253 -36
- package/admin/src/components/SyncProfilesTab.jsx +140 -4
- package/admin/src/components/SyncTab.jsx +161 -35
- 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/docs/sync-strategy-approach-review.md +127 -0
- package/package.json +1 -1
- package/server/src/controllers/config.js +76 -3
- package/server/src/controllers/sync-media.js +24 -0
- package/server/src/routes/index.js +3 -0
- package/server/src/services/bulk-transfer.js +45 -1
- package/server/src/services/dependency-resolver.js +37 -0
- package/server/src/services/sync-execution.js +21 -9
- package/server/src/services/sync-media.js +168 -32
- package/server/src/services/sync-profiles.js +36 -15
- package/server/src/services/sync.js +234 -134
- package/server/src/utils/fetcher.js +7 -0
- 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
|
@@ -27,6 +27,41 @@ module.exports = ({ strapi }) => {
|
|
|
27
27
|
await store.set({ key: LAST_SYNC_STORE_KEY, value: timestamps });
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function getOwnerRelationFieldSet(uid, allowedFields) {
|
|
31
|
+
const contentType = strapi.contentTypes?.[uid];
|
|
32
|
+
const attrs = contentType?.attributes || {};
|
|
33
|
+
const allowed = new Set(allowedFields || []);
|
|
34
|
+
const set = new Set();
|
|
35
|
+
|
|
36
|
+
for (const [field, attr] of Object.entries(attrs)) {
|
|
37
|
+
// Owner/declaring side only: include relation fields that declare a
|
|
38
|
+
// target and are not inverse-only markers.
|
|
39
|
+
if (attr?.type === 'relation' && attr.target && !attr.mappedBy && !attr.inversedBy) {
|
|
40
|
+
if (allowed.size === 0 || allowed.has(field)) set.add(field);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return set;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function selectFieldsForPhase(uid, configuredFields, phase = 'all') {
|
|
48
|
+
const allowed = Array.isArray(configuredFields) ? configuredFields : [];
|
|
49
|
+
if (!uid) return allowed;
|
|
50
|
+
if (phase === 'all') return allowed;
|
|
51
|
+
|
|
52
|
+
const ownerRelationFields = getOwnerRelationFieldSet(uid, allowed);
|
|
53
|
+
if (ownerRelationFields.size === 0) {
|
|
54
|
+
return phase === 'relations' ? ['documentId', 'syncId', 'updatedAt'] : allowed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (phase === 'relations') {
|
|
58
|
+
return ['documentId', 'syncId', 'updatedAt', ...ownerRelationFields];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// entities phase: exclude owner-side relation fields
|
|
62
|
+
return allowed.filter((f) => !ownerRelationFields.has(f));
|
|
63
|
+
}
|
|
64
|
+
|
|
30
65
|
return {
|
|
31
66
|
/**
|
|
32
67
|
* Step 6 + 7 + 10 — Execute a manual / incremental sync for every
|
|
@@ -39,6 +74,7 @@ module.exports = ({ strapi }) => {
|
|
|
39
74
|
const configService = plugin().service('config');
|
|
40
75
|
const syncConfigService = plugin().service('syncConfig');
|
|
41
76
|
const syncProfilesService = plugin().service('syncProfiles');
|
|
77
|
+
const dependencyResolver = plugin().service('dependencyResolver');
|
|
42
78
|
const executionService = plugin().service('syncExecution');
|
|
43
79
|
|
|
44
80
|
const remoteConfig = await configService.getConfig({ safe: false });
|
|
@@ -49,6 +85,47 @@ module.exports = ({ strapi }) => {
|
|
|
49
85
|
const syncConfig = await syncConfigService.getSyncConfig();
|
|
50
86
|
const enabledTypes = (syncConfig.contentTypes || []).filter((ct) => ct.enabled);
|
|
51
87
|
|
|
88
|
+
// Reorder enabled types so dependency targets are processed before
|
|
89
|
+
// dependents. This improves relation consistency during full sync.
|
|
90
|
+
const enabledSet = new Set(enabledTypes.map((ct) => ct.uid));
|
|
91
|
+
const inDegree = new Map();
|
|
92
|
+
const adjacency = new Map();
|
|
93
|
+
enabledTypes.forEach((ct) => {
|
|
94
|
+
inDegree.set(ct.uid, 0);
|
|
95
|
+
adjacency.set(ct.uid, []);
|
|
96
|
+
});
|
|
97
|
+
for (const ct of enabledTypes) {
|
|
98
|
+
try {
|
|
99
|
+
const rels = dependencyResolver.analyzeContentType(ct.uid)?.relations || [];
|
|
100
|
+
for (const rel of rels) {
|
|
101
|
+
const depUid = rel.target;
|
|
102
|
+
if (!enabledSet.has(depUid) || depUid === ct.uid) continue;
|
|
103
|
+
adjacency.get(depUid).push(ct.uid);
|
|
104
|
+
inDegree.set(ct.uid, (inDegree.get(ct.uid) || 0) + 1);
|
|
105
|
+
}
|
|
106
|
+
} catch (_) {
|
|
107
|
+
// Ignore schema parse failures and keep original order fallback.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const queue = enabledTypes.map((ct) => ct.uid).filter((uid) => (inDegree.get(uid) || 0) === 0);
|
|
111
|
+
const orderedUids = [];
|
|
112
|
+
while (queue.length > 0) {
|
|
113
|
+
const uid = queue.shift();
|
|
114
|
+
orderedUids.push(uid);
|
|
115
|
+
for (const next of adjacency.get(uid) || []) {
|
|
116
|
+
const deg = (inDegree.get(next) || 0) - 1;
|
|
117
|
+
inDegree.set(next, deg);
|
|
118
|
+
if (deg === 0) queue.push(next);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Cycle fallback: append any remaining in original order.
|
|
122
|
+
for (const ct of enabledTypes) {
|
|
123
|
+
if (!orderedUids.includes(ct.uid)) orderedUids.push(ct.uid);
|
|
124
|
+
}
|
|
125
|
+
const enabledTypesOrdered = orderedUids
|
|
126
|
+
.map((uid) => enabledTypes.find((ct) => ct.uid === uid))
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
|
|
52
129
|
// Pagination — remote + local fetches are chunked to keep memory bounded
|
|
53
130
|
// for large datasets. Page size is a global setting tunable in the Sync tab.
|
|
54
131
|
const globalExec = (await executionService.getGlobalSettings?.()) || {};
|
|
@@ -57,7 +134,7 @@ module.exports = ({ strapi }) => {
|
|
|
57
134
|
const reportHandle = await syncStatsService.createRunReport({
|
|
58
135
|
runType: 'sync_now',
|
|
59
136
|
trigger: 'manual_sync_now',
|
|
60
|
-
contentTypes:
|
|
137
|
+
contentTypes: enabledTypesOrdered.map((ct) => ct.uid),
|
|
61
138
|
});
|
|
62
139
|
|
|
63
140
|
try {
|
|
@@ -71,7 +148,7 @@ module.exports = ({ strapi }) => {
|
|
|
71
148
|
const conflictStrategy = syncConfig.conflictStrategy || 'latest';
|
|
72
149
|
const results = [];
|
|
73
150
|
|
|
74
|
-
for (const ctConfig of
|
|
151
|
+
for (const ctConfig of enabledTypesOrdered) {
|
|
75
152
|
const { uid, direction, fields } = ctConfig;
|
|
76
153
|
const lastSyncAt = timestamps[uid] || null;
|
|
77
154
|
const syncStartTime = new Date().toISOString();
|
|
@@ -80,94 +157,103 @@ module.exports = ({ strapi }) => {
|
|
|
80
157
|
const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
|
|
81
158
|
|
|
82
159
|
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
160
|
const profileForOptions = await syncProfilesService.getActiveProfileForContentType(uid);
|
|
91
161
|
const syncDeletions = !!(profileForOptions?.syncDeletions);
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
direction,
|
|
95
|
-
conflictStrategy,
|
|
96
|
-
syncDeletions,
|
|
97
|
-
});
|
|
162
|
+
const executionStrategy = profileForOptions?.executionStrategy || 'hybrid_two_pass';
|
|
163
|
+
const phases = executionStrategy === 'one_pass' ? ['all'] : ['entities', 'relations'];
|
|
98
164
|
|
|
99
165
|
let pushed = 0;
|
|
100
166
|
let pulled = 0;
|
|
101
167
|
let errors = 0;
|
|
102
168
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
169
|
+
for (const phase of phases) {
|
|
170
|
+
const phaseFields = selectFieldsForPhase(uid, fields, phase);
|
|
171
|
+
|
|
172
|
+
// Both sides are fetched in pages of `pageSize` records under the
|
|
173
|
+
// hood (see utils/fetcher.js). We aggregate per content-type because
|
|
174
|
+
// the comparator needs the full set to diff by syncId, but each
|
|
175
|
+
// network/DB call still only returns a bounded chunk.
|
|
176
|
+
const localRecords = await fetchLocalRecords(strapi, uid, { fields: phaseFields, lastSyncAt, pageSize });
|
|
177
|
+
const remoteRecords = await fetchRemoteRecords(remoteConfig, uid, { fields: phaseFields, lastSyncAt, pageSize });
|
|
178
|
+
|
|
179
|
+
const diff = compareRecords(localRecords, remoteRecords, {
|
|
180
|
+
direction,
|
|
181
|
+
conflictStrategy,
|
|
182
|
+
syncDeletions: phase === 'relations' ? false : syncDeletions,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Apply field policies to records before pushing/pulling
|
|
186
|
+
for (const { local } of diff.toPush) {
|
|
187
|
+
try {
|
|
188
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(local, fieldPolicies, 'push');
|
|
189
|
+
await applyRemote(remoteConfig, uid, filteredRecord, phaseFields);
|
|
190
|
+
pushed++;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
errors++;
|
|
193
|
+
await logService.log({ action: 'push', contentType: uid, syncId: local.syncId, direction: 'push', status: 'error', message: err.message });
|
|
194
|
+
}
|
|
112
195
|
}
|
|
113
|
-
}
|
|
114
196
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
197
|
+
for (const { remote } of diff.toPull) {
|
|
198
|
+
try {
|
|
199
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(remote, fieldPolicies, 'pull');
|
|
200
|
+
await applyLocal(strapi, uid, filteredRecord, phaseFields);
|
|
201
|
+
pulled++;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
errors++;
|
|
204
|
+
await logService.log({ action: 'pull', contentType: uid, syncId: remote.syncId, direction: 'pull', status: 'error', message: err.message });
|
|
205
|
+
}
|
|
123
206
|
}
|
|
124
|
-
}
|
|
125
207
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
208
|
+
for (const record of diff.toCreateRemote) {
|
|
209
|
+
try {
|
|
210
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'push');
|
|
211
|
+
await applyRemote(remoteConfig, uid, filteredRecord, phaseFields);
|
|
212
|
+
pushed++;
|
|
213
|
+
} catch (err) {
|
|
214
|
+
errors++;
|
|
215
|
+
await logService.log({ action: 'create_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
|
|
216
|
+
}
|
|
134
217
|
}
|
|
135
|
-
}
|
|
136
218
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
219
|
+
for (const record of diff.toCreateLocal) {
|
|
220
|
+
try {
|
|
221
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'pull');
|
|
222
|
+
await applyLocal(strapi, uid, filteredRecord, phaseFields);
|
|
223
|
+
pulled++;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
errors++;
|
|
226
|
+
await logService.log({ action: 'create_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
|
|
227
|
+
}
|
|
145
228
|
}
|
|
146
|
-
}
|
|
147
229
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
230
|
+
// Deletion handling only in non-relations phase
|
|
231
|
+
if (phase !== 'relations') {
|
|
232
|
+
for (const record of diff.toDeleteRemote) {
|
|
233
|
+
try {
|
|
234
|
+
await deleteRemote(remoteConfig, uid, record);
|
|
235
|
+
await logService.log({ action: 'delete_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'success', message: `Deleted remote record ${record.syncId}` });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
errors++;
|
|
238
|
+
await logService.log({ action: 'delete_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const record of diff.toDeleteLocal) {
|
|
243
|
+
try {
|
|
244
|
+
await deleteLocal(strapi, uid, record);
|
|
245
|
+
await logService.log({ action: 'delete_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'success', message: `Deleted local record ${record.syncId}` });
|
|
246
|
+
} catch (err) {
|
|
247
|
+
errors++;
|
|
248
|
+
await logService.log({ action: 'delete_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
165
251
|
}
|
|
166
252
|
}
|
|
167
253
|
|
|
168
254
|
await setLastSyncTimestamp(uid, syncStartTime);
|
|
169
255
|
|
|
170
|
-
const summary = { uid, pushed, pulled, errors, hasFieldPolicies: !!fieldPolicies };
|
|
256
|
+
const summary = { uid, pushed, pulled, errors, hasFieldPolicies: !!fieldPolicies, executionStrategy };
|
|
171
257
|
results.push(summary);
|
|
172
258
|
|
|
173
259
|
await logService.log({
|
|
@@ -175,7 +261,7 @@ module.exports = ({ strapi }) => {
|
|
|
175
261
|
contentType: uid,
|
|
176
262
|
direction,
|
|
177
263
|
status: errors > 0 ? 'partial' : 'success',
|
|
178
|
-
message: `Pushed: ${pushed}, Pulled: ${pulled}, Errors: ${errors}${fieldPolicies ? ' (with field policies)' : ''}`,
|
|
264
|
+
message: `Pushed: ${pushed}, Pulled: ${pulled}, Errors: ${errors}${fieldPolicies ? ' (with field policies)' : ''}${executionStrategy === 'hybrid_two_pass' ? ' (hybrid two-pass)' : ''}`,
|
|
179
265
|
details: summary,
|
|
180
266
|
});
|
|
181
267
|
} catch (err) {
|
|
@@ -243,6 +329,8 @@ module.exports = ({ strapi }) => {
|
|
|
243
329
|
const conflictStrategy = profile?.conflictStrategy || syncConfig.conflictStrategy || 'latest';
|
|
244
330
|
const syncDeletions = !!(profile?.syncDeletions);
|
|
245
331
|
const fields = ctConfig.fields || [];
|
|
332
|
+
const executionStrategy = profile?.executionStrategy || 'hybrid_two_pass';
|
|
333
|
+
const phases = executionStrategy === 'one_pass' ? ['all'] : ['entities', 'relations'];
|
|
246
334
|
|
|
247
335
|
// Field-level policies: prefer the policies on the provided profile,
|
|
248
336
|
// otherwise fall back to the active profile for the content type.
|
|
@@ -270,72 +358,81 @@ module.exports = ({ strapi }) => {
|
|
|
270
358
|
let errors = 0;
|
|
271
359
|
|
|
272
360
|
try {
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
361
|
+
for (const phase of phases) {
|
|
362
|
+
const phaseFields = selectFieldsForPhase(uid, fields, phase);
|
|
363
|
+
const localRecords = await fetchLocalRecords(strapi, uid, { fields: phaseFields, lastSyncAt, pageSize });
|
|
364
|
+
const remoteRecords = await fetchRemoteRecords(remoteConfig, uid, { fields: phaseFields, lastSyncAt, pageSize });
|
|
365
|
+
|
|
366
|
+
const diff = compareRecords(localRecords, remoteRecords, {
|
|
367
|
+
direction,
|
|
368
|
+
conflictStrategy,
|
|
369
|
+
syncDeletions: phase === 'relations' ? false : syncDeletions,
|
|
370
|
+
});
|
|
277
371
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
372
|
+
for (const { local } of diff.toPush) {
|
|
373
|
+
try {
|
|
374
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(local, fieldPolicies, 'push');
|
|
375
|
+
await applyRemote(remoteConfig, uid, filteredRecord, phaseFields);
|
|
376
|
+
pushed++;
|
|
377
|
+
} catch (err) {
|
|
378
|
+
errors++;
|
|
379
|
+
await logService.log({ action: 'push', contentType: uid, syncId: local.syncId, direction: 'push', status: 'error', message: err.message });
|
|
380
|
+
}
|
|
286
381
|
}
|
|
287
|
-
}
|
|
288
382
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
383
|
+
for (const { remote } of diff.toPull) {
|
|
384
|
+
try {
|
|
385
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(remote, fieldPolicies, 'pull');
|
|
386
|
+
await applyLocal(strapi, uid, filteredRecord, phaseFields);
|
|
387
|
+
pulled++;
|
|
388
|
+
} catch (err) {
|
|
389
|
+
errors++;
|
|
390
|
+
await logService.log({ action: 'pull', contentType: uid, syncId: remote.syncId, direction: 'pull', status: 'error', message: err.message });
|
|
391
|
+
}
|
|
297
392
|
}
|
|
298
|
-
}
|
|
299
393
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
394
|
+
for (const record of diff.toCreateRemote) {
|
|
395
|
+
try {
|
|
396
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'push');
|
|
397
|
+
await applyRemote(remoteConfig, uid, filteredRecord, phaseFields);
|
|
398
|
+
pushed++;
|
|
399
|
+
} catch (err) {
|
|
400
|
+
errors++;
|
|
401
|
+
await logService.log({ action: 'create_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
|
|
402
|
+
}
|
|
308
403
|
}
|
|
309
|
-
}
|
|
310
404
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
405
|
+
for (const record of diff.toCreateLocal) {
|
|
406
|
+
try {
|
|
407
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'pull');
|
|
408
|
+
await applyLocal(strapi, uid, filteredRecord, phaseFields);
|
|
409
|
+
pulled++;
|
|
410
|
+
} catch (err) {
|
|
411
|
+
errors++;
|
|
412
|
+
await logService.log({ action: 'create_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
|
|
413
|
+
}
|
|
319
414
|
}
|
|
320
|
-
}
|
|
321
415
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
416
|
+
if (phase !== 'relations') {
|
|
417
|
+
for (const record of diff.toDeleteRemote) {
|
|
418
|
+
try {
|
|
419
|
+
await deleteRemote(remoteConfig, uid, record);
|
|
420
|
+
await logService.log({ action: 'delete_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'success', message: `Deleted remote record ${record.syncId}` });
|
|
421
|
+
} catch (err) {
|
|
422
|
+
errors++;
|
|
423
|
+
await logService.log({ action: 'delete_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
331
426
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
427
|
+
for (const record of diff.toDeleteLocal) {
|
|
428
|
+
try {
|
|
429
|
+
await deleteLocal(strapi, uid, record);
|
|
430
|
+
await logService.log({ action: 'delete_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'success', message: `Deleted local record ${record.syncId}` });
|
|
431
|
+
} catch (err) {
|
|
432
|
+
errors++;
|
|
433
|
+
await logService.log({ action: 'delete_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
339
436
|
}
|
|
340
437
|
}
|
|
341
438
|
|
|
@@ -347,6 +444,7 @@ module.exports = ({ strapi }) => {
|
|
|
347
444
|
pulled,
|
|
348
445
|
errors,
|
|
349
446
|
hasFieldPolicies: !!fieldPolicies,
|
|
447
|
+
executionStrategy,
|
|
350
448
|
profile: profile ? { id: profile.id, name: profile.name } : null,
|
|
351
449
|
};
|
|
352
450
|
|
|
@@ -355,7 +453,7 @@ module.exports = ({ strapi }) => {
|
|
|
355
453
|
contentType: uid,
|
|
356
454
|
direction,
|
|
357
455
|
status: errors > 0 ? 'partial' : 'success',
|
|
358
|
-
message: `Pushed: ${pushed}, Pulled: ${pulled}, Errors: ${errors}${fieldPolicies ? ' (with field policies)' : ''}`,
|
|
456
|
+
message: `Pushed: ${pushed}, Pulled: ${pulled}, Errors: ${errors}${fieldPolicies ? ' (with field policies)' : ''}${executionStrategy === 'hybrid_two_pass' ? ' (hybrid two-pass)' : ''}`,
|
|
359
457
|
details: summary,
|
|
360
458
|
});
|
|
361
459
|
|
|
@@ -412,6 +510,8 @@ module.exports = ({ strapi }) => {
|
|
|
412
510
|
const conflictStrategy = profile?.conflictStrategy || syncConfig.conflictStrategy || 'latest';
|
|
413
511
|
const syncDeletions = !!(profile?.syncDeletions);
|
|
414
512
|
const fields = ctConfig.fields || [];
|
|
513
|
+
const phase = options.phase || 'all';
|
|
514
|
+
const phaseFields = selectFieldsForPhase(uid, fields, phase);
|
|
415
515
|
|
|
416
516
|
let fieldPolicies = null;
|
|
417
517
|
if (profile && !profile.isSimple && Array.isArray(profile.fieldPolicies) && profile.fieldPolicies.length > 0) {
|
|
@@ -430,8 +530,8 @@ module.exports = ({ strapi }) => {
|
|
|
430
530
|
let pulled = 0;
|
|
431
531
|
let errors = 0;
|
|
432
532
|
|
|
433
|
-
const localPageRes = await fetchLocalPage(strapi, uid, { fields, lastSyncAt, page, pageSize });
|
|
434
|
-
const remotePageRes = await fetchRemotePage(remoteConfig, uid, { fields, lastSyncAt, page, pageSize });
|
|
533
|
+
const localPageRes = await fetchLocalPage(strapi, uid, { fields: phaseFields, lastSyncAt, page, pageSize });
|
|
534
|
+
const remotePageRes = await fetchRemotePage(remoteConfig, uid, { fields: phaseFields, lastSyncAt, page, pageSize });
|
|
435
535
|
|
|
436
536
|
const localRecords = localPageRes.records || [];
|
|
437
537
|
const remoteRecords = remotePageRes.records || [];
|
|
@@ -449,7 +549,7 @@ module.exports = ({ strapi }) => {
|
|
|
449
549
|
for (const { local } of diff.toPush) {
|
|
450
550
|
try {
|
|
451
551
|
const filtered = syncProfilesService.filterFieldsByPolicy(local, fieldPolicies, 'push');
|
|
452
|
-
await applyRemote(remoteConfig, uid, filtered,
|
|
552
|
+
await applyRemote(remoteConfig, uid, filtered, phaseFields);
|
|
453
553
|
pushed++;
|
|
454
554
|
} catch (err) {
|
|
455
555
|
errors++;
|
|
@@ -460,7 +560,7 @@ module.exports = ({ strapi }) => {
|
|
|
460
560
|
for (const { remote } of diff.toPull) {
|
|
461
561
|
try {
|
|
462
562
|
const filtered = syncProfilesService.filterFieldsByPolicy(remote, fieldPolicies, 'pull');
|
|
463
|
-
await applyLocal(strapi, uid, filtered,
|
|
563
|
+
await applyLocal(strapi, uid, filtered, phaseFields);
|
|
464
564
|
pulled++;
|
|
465
565
|
} catch (err) {
|
|
466
566
|
errors++;
|
|
@@ -471,7 +571,7 @@ module.exports = ({ strapi }) => {
|
|
|
471
571
|
for (const record of diff.toCreateRemote) {
|
|
472
572
|
try {
|
|
473
573
|
const filtered = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'push');
|
|
474
|
-
await applyRemote(remoteConfig, uid, filtered,
|
|
574
|
+
await applyRemote(remoteConfig, uid, filtered, phaseFields);
|
|
475
575
|
pushed++;
|
|
476
576
|
} catch (err) {
|
|
477
577
|
errors++;
|
|
@@ -482,7 +582,7 @@ module.exports = ({ strapi }) => {
|
|
|
482
582
|
for (const record of diff.toCreateLocal) {
|
|
483
583
|
try {
|
|
484
584
|
const filtered = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'pull');
|
|
485
|
-
await applyLocal(strapi, uid, filtered,
|
|
585
|
+
await applyLocal(strapi, uid, filtered, phaseFields);
|
|
486
586
|
pulled++;
|
|
487
587
|
} catch (err) {
|
|
488
588
|
errors++;
|
|
@@ -12,6 +12,8 @@ async function fetchLocalPage(strapi, uid, { fields, lastSyncAt, page = 1, pageS
|
|
|
12
12
|
start: (page - 1) * size,
|
|
13
13
|
limit: size,
|
|
14
14
|
sort: 'updatedAt:asc',
|
|
15
|
+
// Include relations/components so dependency links can be compared/synced.
|
|
16
|
+
populate: '*',
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
if (lastSyncAt) {
|
|
@@ -43,6 +45,11 @@ async function fetchRemotePage(remoteConfig, uid, { fields, lastSyncAt, page = 1
|
|
|
43
45
|
});
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// Include relations/components so dependency links can be compared/synced.
|
|
49
|
+
// Keep field filters above for scalar fields; Strapi still returns populated
|
|
50
|
+
// relation/component payloads when `populate=*` is set.
|
|
51
|
+
url.searchParams.set('populate', '*');
|
|
52
|
+
|
|
46
53
|
if (lastSyncAt) {
|
|
47
54
|
url.searchParams.set('filters[updatedAt][$gt]', lastSyncAt);
|
|
48
55
|
}
|
|
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.
|