strapi-content-sync-pro 1.0.2 → 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 (33) hide show
  1. package/README.md +65 -18
  2. package/admin/src/components/ConfigTab.jsx +25 -4
  3. package/admin/src/components/HelpTab.jsx +88 -11
  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/clipchamp-screen-recording-script.md +0 -0
  10. package/docs/production-readiness-status.md +34 -0
  11. package/docs/production-readiness-test-matrix.md +151 -0
  12. package/docs/test-environments-setup-legacy.txt +60 -0
  13. package/package.json +1 -1
  14. package/server/src/content-types/index.js +2 -0
  15. package/server/src/content-types/sync-run-report/schema.json +26 -0
  16. package/server/src/controllers/config.js +48 -5
  17. package/server/src/controllers/index.js +2 -0
  18. package/server/src/controllers/sync-log.js +6 -0
  19. package/server/src/controllers/sync-media.js +19 -0
  20. package/server/src/controllers/sync-stats.js +51 -0
  21. package/server/src/controllers/sync.js +9 -3
  22. package/server/src/routes/index.js +13 -0
  23. package/server/src/services/config.js +18 -2
  24. package/server/src/services/index.js +2 -0
  25. package/server/src/services/sync-execution.js +102 -5
  26. package/server/src/services/sync-log.js +36 -0
  27. package/server/src/services/sync-media.js +224 -1
  28. package/server/src/services/sync-profiles.js +92 -4
  29. package/server/src/services/sync-stats.js +353 -0
  30. package/server/src/services/sync.js +186 -100
  31. package/server/src/utils/applier.js +120 -13
  32. package/server/src/utils/comparator.js +22 -6
  33. 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,165 @@ 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;
72
69
 
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
- });
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();
85
78
 
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 });
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
+ }
99
113
  }
100
- }
101
114
 
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 });
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
+ }
110
124
  }
111
- }
112
125
 
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 });
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
+ }
121
135
  }
122
- }
123
136
 
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 });
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
+ }
132
146
  }
133
- }
134
147
 
135
- await setLastSyncTimestamp(uid, syncStartTime);
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
+ }
156
+ }
136
157
 
137
- const summary = { uid, pushed, pulled, errors, hasFieldPolicies: !!fieldPolicies };
138
- results.push(summary);
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
+ }
166
+ }
139
167
 
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
- });
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
+ });
190
+ }
157
191
  }
158
- }
159
192
 
160
- return { syncedAt: new Date().toISOString(), results };
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
+ }
161
211
  },
162
212
 
163
213
  /**
@@ -191,6 +241,7 @@ module.exports = ({ strapi }) => {
191
241
 
192
242
  const direction = profile?.direction || ctConfig.direction || 'both';
193
243
  const conflictStrategy = profile?.conflictStrategy || syncConfig.conflictStrategy || 'latest';
244
+ const syncDeletions = !!(profile?.syncDeletions);
194
245
  const fields = ctConfig.fields || [];
195
246
 
196
247
  // Field-level policies: prefer the policies on the provided profile,
@@ -222,7 +273,7 @@ module.exports = ({ strapi }) => {
222
273
  const localRecords = await fetchLocalRecords(strapi, uid, { fields, lastSyncAt, pageSize });
223
274
  const remoteRecords = await fetchRemoteRecords(remoteConfig, uid, { fields, lastSyncAt, pageSize });
224
275
 
225
- const diff = compareRecords(localRecords, remoteRecords, { direction, conflictStrategy });
276
+ const diff = compareRecords(localRecords, remoteRecords, { direction, conflictStrategy, syncDeletions });
226
277
 
227
278
  for (const { local } of diff.toPush) {
228
279
  try {
@@ -268,6 +319,26 @@ module.exports = ({ strapi }) => {
268
319
  }
269
320
  }
270
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
+
271
342
  await setLastSyncTimestamp(uid, syncStartTime);
272
343
 
273
344
  const summary = {
@@ -352,32 +423,47 @@ module.exports = ({ strapi }) => {
352
423
  * Step 9 — Receive a record pushed from a remote instance.
353
424
  * Now supports field-level policies.
354
425
  */
355
- async receiveRecord(uid, data, syncId) {
426
+ async receiveRecord(uid, data, syncId, isDelete = false, documentId = null) {
356
427
  const logService = plugin().service('syncLog');
357
428
  const syncProfilesService = plugin().service('syncProfiles');
358
429
 
359
- // Get field-level policies from active profile (if any)
360
- const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
361
- const filteredData = syncProfilesService.filterFieldsByPolicy(data, fieldPolicies, 'pull');
430
+ const key = documentId || syncId;
362
431
 
363
432
  try {
364
- 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 }, []);
365
451
 
366
452
  await logService.log({
367
453
  action: 'receive',
368
454
  contentType: uid,
369
- syncId,
455
+ syncId: key,
370
456
  direction: 'pull',
371
457
  status: 'success',
372
- message: `Record ${syncId} received from remote${fieldPolicies ? ' (with field policies)' : ''}`,
458
+ message: `Record ${key} received from remote${fieldPolicies ? ' (with field policies)' : ''}`,
373
459
  });
374
460
 
375
461
  return { success: true };
376
462
  } catch (err) {
377
463
  await logService.log({
378
- action: 'receive',
464
+ action: isDelete ? 'receive_delete' : 'receive',
379
465
  contentType: uid,
380
- syncId,
466
+ syncId: key,
381
467
  direction: 'pull',
382
468
  status: 'error',
383
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 };
@@ -8,26 +8,33 @@
8
8
  * @param {Object} options
9
9
  * @param {string} options.direction – "push" | "pull" | "both"
10
10
  * @param {string} options.conflictStrategy – "latest" | "local_wins" | "remote_wins"
11
- * @returns {{ toPush, toPull, toCreateRemote, toCreateLocal }}
11
+ * @param {boolean} options.syncDeletions – propagate missing records as deletions
12
+ * @returns {{ toPush, toPull, toCreateRemote, toCreateLocal, toDeleteRemote, toDeleteLocal }}
12
13
  */
13
14
  function compareRecords(localRecords, remoteRecords, options = {}) {
14
- const { direction = 'both', conflictStrategy = 'latest' } = options;
15
+ const { direction = 'both', conflictStrategy = 'latest', syncDeletions = false } = options;
15
16
 
16
17
  const result = {
17
18
  toPush: [],
18
19
  toPull: [],
19
20
  toCreateRemote: [],
20
21
  toCreateLocal: [],
22
+ toDeleteRemote: [],
23
+ toDeleteLocal: [],
21
24
  };
22
25
 
23
26
  const localBySyncId = new Map();
24
27
  const remoteBySyncId = new Map();
25
28
 
29
+ const keyOf = (r) => r && (r.documentId || r.syncId);
30
+
26
31
  for (const r of localRecords) {
27
- if (r.syncId) localBySyncId.set(r.syncId, r);
32
+ const k = keyOf(r);
33
+ if (k) localBySyncId.set(k, r);
28
34
  }
29
35
  for (const r of remoteRecords) {
30
- if (r.syncId) remoteBySyncId.set(r.syncId, r);
36
+ const k = keyOf(r);
37
+ if (k) remoteBySyncId.set(k, r);
31
38
  }
32
39
 
33
40
  // Records that exist on both sides
@@ -43,7 +50,11 @@ function compareRecords(localRecords, remoteRecords, options = {}) {
43
50
  result.toPull.push({ local: localRecord, remote: remoteRecord });
44
51
  }
45
52
  } else if (direction === 'push' || direction === 'both') {
46
- result.toCreateRemote.push(localRecord);
53
+ if (syncDeletions && direction !== 'both') {
54
+ result.toDeleteRemote.push(localRecord);
55
+ } else {
56
+ result.toCreateRemote.push(localRecord);
57
+ }
47
58
  }
48
59
  }
49
60
 
@@ -51,7 +62,12 @@ function compareRecords(localRecords, remoteRecords, options = {}) {
51
62
  for (const [syncId] of remoteBySyncId) {
52
63
  if (!localBySyncId.has(syncId)) {
53
64
  if (direction === 'pull' || direction === 'both') {
54
- result.toCreateLocal.push(remoteBySyncId.get(syncId));
65
+ const remoteRecord = remoteBySyncId.get(syncId);
66
+ if (syncDeletions && direction !== 'both') {
67
+ result.toDeleteLocal.push(remoteRecord);
68
+ } else {
69
+ result.toCreateLocal.push(remoteRecord);
70
+ }
55
71
  }
56
72
  }
57
73
  }
@@ -19,7 +19,7 @@ async function fetchLocalPage(strapi, uid, { fields, lastSyncAt, page = 1, pageS
19
19
  }
20
20
 
21
21
  if (fields && fields.length > 0) {
22
- params.fields = [...new Set([...fields, 'syncId', 'updatedAt'])];
22
+ params.fields = [...new Set([...fields, 'documentId', 'syncId', 'updatedAt'])];
23
23
  }
24
24
 
25
25
  const records = (await strapi.documents(uid).findMany(params)) || [];
@@ -37,7 +37,7 @@ async function fetchRemotePage(remoteConfig, uid, { fields, lastSyncAt, page = 1
37
37
  const url = new URL(`/api/${pluralName}`, baseUrl);
38
38
 
39
39
  if (fields && fields.length > 0) {
40
- const allFields = [...new Set([...fields, 'syncId', 'updatedAt'])];
40
+ const allFields = [...new Set([...fields, 'documentId', 'syncId', 'updatedAt'])];
41
41
  allFields.forEach((f, i) => {
42
42
  url.searchParams.set(`fields[${i}]`, f);
43
43
  });
@@ -123,10 +123,19 @@ async function fetchRemoteRecords(remoteConfig, uid, options = {}) {
123
123
  * e.g. "api::product.product" → "products"
124
124
  */
125
125
  function uidToPluralEndpoint(uid) {
126
+ const contentType = global.strapi?.contentTypes?.[uid];
127
+ const configuredPlural = contentType?.info?.pluralName;
128
+ if (configuredPlural) {
129
+ return configuredPlural;
130
+ }
131
+
126
132
  const parts = uid.split('.');
127
133
  const modelName = parts[parts.length - 1];
128
134
  if (modelName.endsWith('s')) return modelName;
129
135
  if (modelName.endsWith('y')) return modelName.slice(0, -1) + 'ies';
136
+ if (modelName.endsWith('ch') || modelName.endsWith('sh') || modelName.endsWith('x') || modelName.endsWith('z')) {
137
+ return modelName + 'es';
138
+ }
130
139
  return modelName + 's';
131
140
  }
132
141