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.
Files changed (48) hide show
  1. package/README.md +84 -25
  2. package/admin/src/components/ConfigTab.jsx +29 -6
  3. package/admin/src/components/HelpTab.jsx +131 -32
  4. package/admin/src/components/MediaTab.jsx +7 -0
  5. package/admin/src/components/StatsTab.jsx +470 -0
  6. package/admin/src/components/SyncProfilesTab.jsx +63 -5
  7. package/admin/src/components/SyncTab.jsx +51 -7
  8. package/admin/src/pages/App/index.jsx +3 -0
  9. package/docs/Screenshot 2026-04-20 160506.png +0 -0
  10. package/docs/Screenshot 2026-04-20 160558.png +0 -0
  11. package/docs/Screenshot 2026-04-20 175903.png +0 -0
  12. package/docs/Screenshot 2026-04-20 175931.png +0 -0
  13. package/docs/Screenshot 2026-04-20 180001.png +0 -0
  14. package/docs/Screenshot 2026-04-20 180041.png +0 -0
  15. package/docs/Screenshot 2026-04-20 180116.png +0 -0
  16. package/docs/Screenshot 2026-04-20 180135.png +0 -0
  17. package/docs/Screenshot 2026-04-20 180202.png +0 -0
  18. package/docs/Screenshot 2026-04-20 180228.png +0 -0
  19. package/docs/Screenshot 2026-04-20 180251.png +0 -0
  20. package/docs/Screenshot 2026-04-20 180301.png +0 -0
  21. package/docs/clipchamp-screen-recording-script.md +0 -0
  22. package/docs/logo-horizontal.svg +33 -0
  23. package/docs/logo-mark.svg +38 -0
  24. package/docs/logo-square.svg +27 -0
  25. package/docs/production-readiness-status.md +34 -0
  26. package/docs/production-readiness-test-matrix.md +151 -0
  27. package/docs/test-environments-setup-legacy.txt +60 -0
  28. package/package.json +2 -1
  29. package/server/src/content-types/index.js +2 -0
  30. package/server/src/content-types/sync-run-report/schema.json +26 -0
  31. package/server/src/controllers/config.js +48 -5
  32. package/server/src/controllers/index.js +2 -0
  33. package/server/src/controllers/sync-log.js +6 -0
  34. package/server/src/controllers/sync-media.js +19 -0
  35. package/server/src/controllers/sync-stats.js +51 -0
  36. package/server/src/controllers/sync.js +9 -3
  37. package/server/src/routes/index.js +13 -0
  38. package/server/src/services/config.js +18 -2
  39. package/server/src/services/index.js +2 -0
  40. package/server/src/services/sync-execution.js +102 -5
  41. package/server/src/services/sync-log.js +36 -0
  42. package/server/src/services/sync-media.js +224 -1
  43. package/server/src/services/sync-profiles.js +92 -4
  44. package/server/src/services/sync-stats.js +353 -0
  45. package/server/src/services/sync.js +324 -97
  46. package/server/src/utils/applier.js +120 -13
  47. package/server/src/utils/comparator.js +22 -6
  48. 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 timestamps = await getLastSyncTimestamps();
62
- const conflictStrategy = syncConfig.conflictStrategy || 'latest';
63
- const results = [];
56
+ const syncStatsService = plugin().service('syncStats');
57
+ const reportHandle = await syncStatsService.createRunReport({
58
+ runType: 'sync_now',
59
+ trigger: 'manual_sync_now',
60
+ contentTypes: enabledTypes.map((ct) => ct.uid),
61
+ });
64
62
 
65
- for (const ctConfig of enabledTypes) {
66
- const { uid, direction, fields } = ctConfig;
67
- const lastSyncAt = timestamps[uid] || null;
68
- const syncStartTime = new Date().toISOString();
63
+ try {
64
+ if (enabledTypes.length === 0) {
65
+ throw new Error('No content types configured for sync');
66
+ }
69
67
 
70
- // Get field-level policies from active profile (if any)
71
- const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
68
+ const pageSize = Number(globalExec.syncPageSize) || 100;
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
- try {
74
- // Both sides are fetched in pages of `pageSize` records under the
75
- // hood (see utils/fetcher.js). We aggregate per content-type because
76
- // the comparator needs the full set to diff by syncId, but each
77
- // network/DB call still only returns a bounded chunk.
78
- const localRecords = await fetchLocalRecords(strapi, uid, { fields, lastSyncAt, pageSize });
79
- const remoteRecords = await fetchRemoteRecords(remoteConfig, uid, { fields, lastSyncAt, pageSize });
80
-
81
- const diff = compareRecords(localRecords, remoteRecords, {
82
- direction,
83
- conflictStrategy,
84
- });
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
- let pushed = 0;
87
- let pulled = 0;
88
- let errors = 0;
89
-
90
- // Apply field policies to records before pushing/pulling
91
- for (const { local } of diff.toPush) {
92
- try {
93
- const filteredRecord = syncProfilesService.filterFieldsByPolicy(local, fieldPolicies, 'push');
94
- await applyRemote(remoteConfig, uid, filteredRecord, fields);
95
- pushed++;
96
- } catch (err) {
97
- errors++;
98
- await logService.log({ action: 'push', contentType: uid, syncId: local.syncId, direction: 'push', status: 'error', message: err.message });
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
- for (const { remote } of diff.toPull) {
103
- try {
104
- const filteredRecord = syncProfilesService.filterFieldsByPolicy(remote, fieldPolicies, 'pull');
105
- await applyLocal(strapi, uid, filteredRecord, fields);
106
- pulled++;
107
- } catch (err) {
108
- errors++;
109
- await logService.log({ action: 'pull', contentType: uid, syncId: remote.syncId, direction: 'pull', status: 'error', message: err.message });
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
- for (const record of diff.toCreateRemote) {
114
- try {
115
- const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'push');
116
- await applyRemote(remoteConfig, uid, filteredRecord, fields);
117
- pushed++;
118
- } catch (err) {
119
- errors++;
120
- await logService.log({ action: 'create_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
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
- for (const record of diff.toCreateLocal) {
125
- try {
126
- const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'pull');
127
- await applyLocal(strapi, uid, filteredRecord, fields);
128
- pulled++;
129
- } catch (err) {
130
- errors++;
131
- await logService.log({ action: 'create_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
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
- await setLastSyncTimestamp(uid, syncStartTime);
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
- const summary = { uid, pushed, pulled, errors, hasFieldPolicies: !!fieldPolicies };
138
- results.push(summary);
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
- await logService.log({
141
- action: 'sync_complete',
142
- contentType: uid,
143
- direction,
144
- status: errors > 0 ? 'partial' : 'success',
145
- message: `Pushed: ${pushed}, Pulled: ${pulled}, Errors: ${errors}${fieldPolicies ? ' (with field policies)' : ''}`,
146
- details: summary,
147
- });
148
- } catch (err) {
149
- results.push({ uid, error: err.message });
150
- await logService.log({
151
- action: 'sync_error',
152
- contentType: uid,
153
- direction,
154
- status: 'error',
155
- message: err.message,
156
- });
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
- return { syncedAt: new Date().toISOString(), results };
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
- // Get field-level policies from active profile (if any)
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
- await applyLocal(strapi, uid, { ...filteredData, syncId }, []);
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 ${syncId} received from remote${fieldPolicies ? ' (with field policies)' : ''}`,
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 syncId = record.syncId;
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}:${syncId}`);
23
+ if (key) markAsRemoteUpdate(`${uid}:${key}`);
15
24
 
16
- const existing = await strapi.documents(uid).findMany({
17
- filters: { syncId },
18
- limit: 1,
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 (existing && existing.length > 0) {
48
+ if (existingDocumentId) {
22
49
  return strapi.documents(uid).update({
23
- documentId: existing[0].documentId,
50
+ documentId: existingDocumentId,
24
51
  data,
25
52
  });
26
53
  }
27
54
 
28
- data.syncId = syncId;
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('/strapi-content-sync-pro/receive', baseUrl);
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
- syncId: record.syncId,
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, documentId, createdAt, updatedAt, publishedAt,
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
- module.exports = { applyLocal, applyRemote, filterFields };
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 };