strapi-content-sync-pro 1.0.1 → 1.0.3
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 +84 -25
- package/admin/src/components/ConfigTab.jsx +29 -6
- package/admin/src/components/HelpTab.jsx +131 -32
- package/admin/src/components/MediaTab.jsx +7 -0
- package/admin/src/components/StatsTab.jsx +470 -0
- package/admin/src/components/SyncProfilesTab.jsx +63 -5
- package/admin/src/components/SyncTab.jsx +51 -7
- package/admin/src/pages/App/index.jsx +3 -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/logo-horizontal.svg +33 -0
- package/docs/logo-mark.svg +38 -0
- package/docs/logo-square.svg +27 -0
- package/docs/production-readiness-status.md +34 -0
- package/docs/production-readiness-test-matrix.md +151 -0
- package/docs/test-environments-setup-legacy.txt +60 -0
- package/package.json +2 -1
- package/server/src/content-types/index.js +2 -0
- package/server/src/content-types/sync-run-report/schema.json +26 -0
- package/server/src/controllers/config.js +48 -5
- package/server/src/controllers/index.js +2 -0
- package/server/src/controllers/sync-log.js +6 -0
- package/server/src/controllers/sync-media.js +19 -0
- package/server/src/controllers/sync-stats.js +51 -0
- package/server/src/controllers/sync.js +9 -3
- package/server/src/routes/index.js +13 -0
- package/server/src/services/config.js +18 -2
- package/server/src/services/index.js +2 -0
- package/server/src/services/sync-execution.js +102 -5
- package/server/src/services/sync-log.js +36 -0
- package/server/src/services/sync-media.js +224 -1
- package/server/src/services/sync-profiles.js +92 -4
- package/server/src/services/sync-stats.js +353 -0
- package/server/src/services/sync.js +324 -97
- package/server/src/utils/applier.js +120 -13
- package/server/src/utils/comparator.js +22 -6
- package/server/src/utils/fetcher.js +11 -2
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { fetchLocalRecords, fetchRemoteRecords } = 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,327 @@ 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
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
try {
|
|
64
|
+
if (enabledTypes.length === 0) {
|
|
65
|
+
throw new Error('No content types configured for sync');
|
|
66
|
+
}
|
|
69
67
|
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
const pageSize = Number(globalExec.syncPageSize) || 100;
|
|
69
|
+
|
|
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);
|
|
81
|
+
|
|
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
|
+
}
|
|
113
|
+
}
|
|
72
114
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
conflictStrategy,
|
|
84
|
-
});
|
|
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
|
+
}
|
|
124
|
+
}
|
|
85
125
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 });
|
|
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
|
+
}
|
|
99
135
|
}
|
|
100
|
-
}
|
|
101
136
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
}
|
|
110
146
|
}
|
|
111
|
-
}
|
|
112
147
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
}
|
|
121
156
|
}
|
|
122
|
-
}
|
|
123
157
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
|
132
166
|
}
|
|
167
|
+
|
|
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
|
+
});
|
|
133
190
|
}
|
|
191
|
+
}
|
|
192
|
+
|
|
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
|
+
}
|
|
211
|
+
},
|
|
134
212
|
|
|
135
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Sync a single content type using a given profile.
|
|
215
|
+
* Called by the execution service (on-demand / scheduled / live runs).
|
|
216
|
+
*
|
|
217
|
+
* options:
|
|
218
|
+
* - profile: sync profile { contentType, direction, conflictStrategy, isSimple, fieldPolicies }
|
|
219
|
+
* - syncDependencies: boolean (currently informational; dependency resolution handled upstream)
|
|
220
|
+
* - dependencyDepth: number
|
|
221
|
+
*/
|
|
222
|
+
async syncContentType(uid, options = {}) {
|
|
223
|
+
if (!uid) {
|
|
224
|
+
throw new Error('Content type uid is required');
|
|
225
|
+
}
|
|
136
226
|
|
|
137
|
-
|
|
138
|
-
|
|
227
|
+
const logService = plugin().service('syncLog');
|
|
228
|
+
const configService = plugin().service('config');
|
|
229
|
+
const syncConfigService = plugin().service('syncConfig');
|
|
230
|
+
const syncProfilesService = plugin().service('syncProfiles');
|
|
231
|
+
const executionService = plugin().service('syncExecution');
|
|
139
232
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
233
|
+
const remoteConfig = await configService.getConfig({ safe: false });
|
|
234
|
+
if (!remoteConfig || !remoteConfig.baseUrl) {
|
|
235
|
+
throw new Error('Remote server not configured');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { profile } = options;
|
|
239
|
+
const syncConfig = await syncConfigService.getSyncConfig();
|
|
240
|
+
const ctConfig = (syncConfig.contentTypes || []).find((ct) => ct.uid === uid) || { uid, fields: [] };
|
|
241
|
+
|
|
242
|
+
const direction = profile?.direction || ctConfig.direction || 'both';
|
|
243
|
+
const conflictStrategy = profile?.conflictStrategy || syncConfig.conflictStrategy || 'latest';
|
|
244
|
+
const syncDeletions = !!(profile?.syncDeletions);
|
|
245
|
+
const fields = ctConfig.fields || [];
|
|
246
|
+
|
|
247
|
+
// Field-level policies: prefer the policies on the provided profile,
|
|
248
|
+
// otherwise fall back to the active profile for the content type.
|
|
249
|
+
let fieldPolicies = null;
|
|
250
|
+
if (profile) {
|
|
251
|
+
if (!profile.isSimple && Array.isArray(profile.fieldPolicies) && profile.fieldPolicies.length > 0) {
|
|
252
|
+
fieldPolicies = {};
|
|
253
|
+
for (const fp of profile.fieldPolicies) {
|
|
254
|
+
fieldPolicies[fp.field] = fp.direction;
|
|
255
|
+
}
|
|
157
256
|
}
|
|
257
|
+
} else {
|
|
258
|
+
fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
|
|
158
259
|
}
|
|
159
260
|
|
|
160
|
-
|
|
261
|
+
const globalExec = (await executionService.getGlobalSettings?.()) || {};
|
|
262
|
+
const pageSize = Number(globalExec.syncPageSize) || 100;
|
|
263
|
+
|
|
264
|
+
const timestamps = await getLastSyncTimestamps();
|
|
265
|
+
const lastSyncAt = timestamps[uid] || null;
|
|
266
|
+
const syncStartTime = new Date().toISOString();
|
|
267
|
+
|
|
268
|
+
let pushed = 0;
|
|
269
|
+
let pulled = 0;
|
|
270
|
+
let errors = 0;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const localRecords = await fetchLocalRecords(strapi, uid, { fields, lastSyncAt, pageSize });
|
|
274
|
+
const remoteRecords = await fetchRemoteRecords(remoteConfig, uid, { fields, lastSyncAt, pageSize });
|
|
275
|
+
|
|
276
|
+
const diff = compareRecords(localRecords, remoteRecords, { direction, conflictStrategy, syncDeletions });
|
|
277
|
+
|
|
278
|
+
for (const { local } of diff.toPush) {
|
|
279
|
+
try {
|
|
280
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(local, fieldPolicies, 'push');
|
|
281
|
+
await applyRemote(remoteConfig, uid, filteredRecord, fields);
|
|
282
|
+
pushed++;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
errors++;
|
|
285
|
+
await logService.log({ action: 'push', contentType: uid, syncId: local.syncId, direction: 'push', status: 'error', message: err.message });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const { remote } of diff.toPull) {
|
|
290
|
+
try {
|
|
291
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(remote, fieldPolicies, 'pull');
|
|
292
|
+
await applyLocal(strapi, uid, filteredRecord, fields);
|
|
293
|
+
pulled++;
|
|
294
|
+
} catch (err) {
|
|
295
|
+
errors++;
|
|
296
|
+
await logService.log({ action: 'pull', contentType: uid, syncId: remote.syncId, direction: 'pull', status: 'error', message: err.message });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const record of diff.toCreateRemote) {
|
|
301
|
+
try {
|
|
302
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'push');
|
|
303
|
+
await applyRemote(remoteConfig, uid, filteredRecord, fields);
|
|
304
|
+
pushed++;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
errors++;
|
|
307
|
+
await logService.log({ action: 'create_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const record of diff.toCreateLocal) {
|
|
312
|
+
try {
|
|
313
|
+
const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'pull');
|
|
314
|
+
await applyLocal(strapi, uid, filteredRecord, fields);
|
|
315
|
+
pulled++;
|
|
316
|
+
} catch (err) {
|
|
317
|
+
errors++;
|
|
318
|
+
await logService.log({ action: 'create_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
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
|
+
|
|
342
|
+
await setLastSyncTimestamp(uid, syncStartTime);
|
|
343
|
+
|
|
344
|
+
const summary = {
|
|
345
|
+
uid,
|
|
346
|
+
pushed,
|
|
347
|
+
pulled,
|
|
348
|
+
errors,
|
|
349
|
+
hasFieldPolicies: !!fieldPolicies,
|
|
350
|
+
profile: profile ? { id: profile.id, name: profile.name } : null,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
await logService.log({
|
|
354
|
+
action: 'sync_complete',
|
|
355
|
+
contentType: uid,
|
|
356
|
+
direction,
|
|
357
|
+
status: errors > 0 ? 'partial' : 'success',
|
|
358
|
+
message: `Pushed: ${pushed}, Pulled: ${pulled}, Errors: ${errors}${fieldPolicies ? ' (with field policies)' : ''}`,
|
|
359
|
+
details: summary,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return { syncedAt: new Date().toISOString(), ...summary };
|
|
363
|
+
} catch (err) {
|
|
364
|
+
await logService.log({
|
|
365
|
+
action: 'sync_error',
|
|
366
|
+
contentType: uid,
|
|
367
|
+
direction,
|
|
368
|
+
status: 'error',
|
|
369
|
+
message: err.message,
|
|
370
|
+
});
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
161
373
|
},
|
|
162
374
|
|
|
163
375
|
/**
|
|
@@ -211,32 +423,47 @@ module.exports = ({ strapi }) => {
|
|
|
211
423
|
* Step 9 — Receive a record pushed from a remote instance.
|
|
212
424
|
* Now supports field-level policies.
|
|
213
425
|
*/
|
|
214
|
-
async receiveRecord(uid, data, syncId) {
|
|
426
|
+
async receiveRecord(uid, data, syncId, isDelete = false, documentId = null) {
|
|
215
427
|
const logService = plugin().service('syncLog');
|
|
216
428
|
const syncProfilesService = plugin().service('syncProfiles');
|
|
217
429
|
|
|
218
|
-
|
|
219
|
-
const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
|
|
220
|
-
const filteredData = syncProfilesService.filterFieldsByPolicy(data, fieldPolicies, 'pull');
|
|
430
|
+
const key = documentId || syncId;
|
|
221
431
|
|
|
222
432
|
try {
|
|
223
|
-
|
|
433
|
+
if (isDelete) {
|
|
434
|
+
await deleteLocal(strapi, uid, { documentId, syncId });
|
|
435
|
+
await logService.log({
|
|
436
|
+
action: 'receive_delete',
|
|
437
|
+
contentType: uid,
|
|
438
|
+
syncId: key,
|
|
439
|
+
direction: 'pull',
|
|
440
|
+
status: 'success',
|
|
441
|
+
message: `Delete received for ${key} from remote`,
|
|
442
|
+
});
|
|
443
|
+
return { success: true, deleted: true };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Get field-level policies from active profile (if any)
|
|
447
|
+
const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
|
|
448
|
+
const filteredData = syncProfilesService.filterFieldsByPolicy(data, fieldPolicies, 'pull');
|
|
449
|
+
|
|
450
|
+
await applyLocal(strapi, uid, { ...filteredData, documentId, syncId }, []);
|
|
224
451
|
|
|
225
452
|
await logService.log({
|
|
226
453
|
action: 'receive',
|
|
227
454
|
contentType: uid,
|
|
228
|
-
syncId,
|
|
455
|
+
syncId: key,
|
|
229
456
|
direction: 'pull',
|
|
230
457
|
status: 'success',
|
|
231
|
-
message: `Record ${
|
|
458
|
+
message: `Record ${key} received from remote${fieldPolicies ? ' (with field policies)' : ''}`,
|
|
232
459
|
});
|
|
233
460
|
|
|
234
461
|
return { success: true };
|
|
235
462
|
} catch (err) {
|
|
236
463
|
await logService.log({
|
|
237
|
-
action: 'receive',
|
|
464
|
+
action: isDelete ? 'receive_delete' : 'receive',
|
|
238
465
|
contentType: uid,
|
|
239
|
-
syncId,
|
|
466
|
+
syncId: key,
|
|
240
467
|
direction: 'pull',
|
|
241
468
|
status: 'error',
|
|
242
469
|
message: err.message,
|
|
@@ -1,31 +1,60 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { strapi: strapiPackageConfig = {} } = require('../../../package.json');
|
|
3
4
|
const { generateSignature } = require('./hmac');
|
|
4
5
|
const { markAsRemoteUpdate } = require('./sync-guard');
|
|
5
6
|
|
|
7
|
+
const PLUGIN_ID = strapiPackageConfig.name || 'strapi-content-sync-pro';
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* Apply a record received from a remote instance to the local database.
|
|
11
|
+
*
|
|
12
|
+
* Strapi v5 identifies entities by `documentId` (stable across instances),
|
|
13
|
+
* while some legacy plugin installs add a custom `syncId` attribute. Prefer
|
|
14
|
+
* `documentId` and fall back to `syncId` for back-compat.
|
|
8
15
|
*/
|
|
9
16
|
async function applyLocal(strapi, uid, record, fields) {
|
|
10
17
|
const data = filterFields(record, fields);
|
|
11
|
-
const
|
|
18
|
+
const documentId = record.documentId || null;
|
|
19
|
+
const syncId = record.syncId || null;
|
|
20
|
+
const key = documentId || syncId;
|
|
12
21
|
|
|
13
22
|
// Mark so the afterCreate/afterUpdate hook skips re-pushing
|
|
14
|
-
markAsRemoteUpdate(`${uid}:${
|
|
23
|
+
if (key) markAsRemoteUpdate(`${uid}:${key}`);
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
let existingDocumentId = null;
|
|
26
|
+
|
|
27
|
+
if (documentId) {
|
|
28
|
+
try {
|
|
29
|
+
const found = await strapi.documents(uid).findOne({ documentId });
|
|
30
|
+
if (found) existingDocumentId = found.documentId || documentId;
|
|
31
|
+
} catch {
|
|
32
|
+
// fall through and try syncId lookup
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!existingDocumentId && syncId) {
|
|
37
|
+
try {
|
|
38
|
+
const existing = await strapi.documents(uid).findMany({
|
|
39
|
+
filters: { syncId },
|
|
40
|
+
limit: 1,
|
|
41
|
+
});
|
|
42
|
+
if (existing && existing.length > 0) existingDocumentId = existing[0].documentId;
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore — treat as create
|
|
45
|
+
}
|
|
46
|
+
}
|
|
20
47
|
|
|
21
|
-
if (
|
|
48
|
+
if (existingDocumentId) {
|
|
22
49
|
return strapi.documents(uid).update({
|
|
23
|
-
documentId:
|
|
50
|
+
documentId: existingDocumentId,
|
|
24
51
|
data,
|
|
25
52
|
});
|
|
26
53
|
}
|
|
27
54
|
|
|
28
|
-
|
|
55
|
+
// Create: preserve documentId so the two instances share identity
|
|
56
|
+
if (documentId) data.documentId = documentId;
|
|
57
|
+
if (syncId) data.syncId = syncId;
|
|
29
58
|
return strapi.documents(uid).create({ data });
|
|
30
59
|
}
|
|
31
60
|
|
|
@@ -34,12 +63,13 @@ async function applyLocal(strapi, uid, record, fields) {
|
|
|
34
63
|
*/
|
|
35
64
|
async function applyRemote(remoteConfig, uid, record, fields) {
|
|
36
65
|
const { baseUrl, apiToken, sharedSecret } = remoteConfig;
|
|
37
|
-
const url = new URL(
|
|
66
|
+
const url = new URL(`/api/${PLUGIN_ID}/receive`, baseUrl);
|
|
38
67
|
|
|
39
68
|
const body = {
|
|
40
69
|
uid,
|
|
41
70
|
data: filterFields(record, fields),
|
|
42
|
-
|
|
71
|
+
documentId: record.documentId || null,
|
|
72
|
+
syncId: record.syncId || null,
|
|
43
73
|
};
|
|
44
74
|
|
|
45
75
|
const timestamp = Date.now().toString();
|
|
@@ -66,14 +96,18 @@ async function applyRemote(remoteConfig, uid, record, fields) {
|
|
|
66
96
|
|
|
67
97
|
/**
|
|
68
98
|
* Return only the requested fields from a record, stripping Strapi internals.
|
|
99
|
+
*
|
|
100
|
+
* `documentId` and `syncId` are preserved (when present) so the remote side
|
|
101
|
+
* can upsert against the same cross-instance identity.
|
|
69
102
|
*/
|
|
70
103
|
function filterFields(record, fields) {
|
|
71
104
|
if (!fields || fields.length === 0) {
|
|
72
105
|
const {
|
|
73
|
-
id,
|
|
106
|
+
id, createdAt, updatedAt, publishedAt,
|
|
74
107
|
createdBy, updatedBy, locale, localizations,
|
|
75
108
|
...data
|
|
76
109
|
} = record;
|
|
110
|
+
// Keep documentId/syncId on the payload; strip createdAt/updatedAt/etc.
|
|
77
111
|
return data;
|
|
78
112
|
}
|
|
79
113
|
|
|
@@ -83,7 +117,80 @@ function filterFields(record, fields) {
|
|
|
83
117
|
data[field] = record[field];
|
|
84
118
|
}
|
|
85
119
|
}
|
|
120
|
+
if (record.documentId !== undefined && data.documentId === undefined) {
|
|
121
|
+
data.documentId = record.documentId;
|
|
122
|
+
}
|
|
123
|
+
if (record.syncId !== undefined && data.syncId === undefined) {
|
|
124
|
+
data.syncId = record.syncId;
|
|
125
|
+
}
|
|
86
126
|
return data;
|
|
87
127
|
}
|
|
88
128
|
|
|
89
|
-
|
|
129
|
+
async function deleteLocal(strapi, uid, record) {
|
|
130
|
+
const documentId = record?.documentId || null;
|
|
131
|
+
const syncId = record?.syncId || null;
|
|
132
|
+
const key = documentId || syncId;
|
|
133
|
+
if (!key) return { skipped: true, reason: 'missing_documentId_and_syncId' };
|
|
134
|
+
|
|
135
|
+
let existingDocumentId = null;
|
|
136
|
+
|
|
137
|
+
if (documentId) {
|
|
138
|
+
try {
|
|
139
|
+
const found = await strapi.documents(uid).findOne({ documentId });
|
|
140
|
+
if (found) existingDocumentId = found.documentId || documentId;
|
|
141
|
+
} catch { /* ignore */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!existingDocumentId && syncId) {
|
|
145
|
+
try {
|
|
146
|
+
const existing = await strapi.documents(uid).findMany({
|
|
147
|
+
filters: { syncId },
|
|
148
|
+
limit: 1,
|
|
149
|
+
});
|
|
150
|
+
if (existing && existing.length > 0) existingDocumentId = existing[0].documentId;
|
|
151
|
+
} catch { /* ignore */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!existingDocumentId) {
|
|
155
|
+
return { skipped: true, reason: 'not_found' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
markAsRemoteUpdate(`${uid}:${key}`);
|
|
159
|
+
await strapi.documents(uid).delete({ documentId: existingDocumentId });
|
|
160
|
+
return { deleted: true };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function deleteRemote(remoteConfig, uid, record) {
|
|
164
|
+
const { baseUrl, apiToken, sharedSecret } = remoteConfig;
|
|
165
|
+
const url = new URL(`/api/${PLUGIN_ID}/receive`, baseUrl);
|
|
166
|
+
|
|
167
|
+
const body = {
|
|
168
|
+
uid,
|
|
169
|
+
documentId: record?.documentId || null,
|
|
170
|
+
syncId: record?.syncId || null,
|
|
171
|
+
delete: true,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const timestamp = Date.now().toString();
|
|
175
|
+
const signature = generateSignature(body, sharedSecret, timestamp);
|
|
176
|
+
|
|
177
|
+
const response = await fetch(url.toString(), {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: `Bearer ${apiToken}`,
|
|
181
|
+
'Content-Type': 'application/json',
|
|
182
|
+
'x-sync-signature': signature,
|
|
183
|
+
'x-sync-timestamp': timestamp,
|
|
184
|
+
},
|
|
185
|
+
body: JSON.stringify(body),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
const text = await response.text();
|
|
190
|
+
throw new Error(`Remote delete failed for ${uid}: ${response.status} – ${text}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return response.json();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = { applyLocal, applyRemote, deleteLocal, deleteRemote, filterFields };
|