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.
- package/README.md +65 -18
- package/admin/src/components/ConfigTab.jsx +25 -4
- package/admin/src/components/HelpTab.jsx +88 -11
- 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/clipchamp-screen-recording-script.md +0 -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 +1 -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 +186 -100
- 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,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
|
|
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
|
-
const fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
|
|
68
|
+
const pageSize = Number(globalExec.syncPageSize) || 100;
|
|
72
69
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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 };
|
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
32
|
+
const k = keyOf(r);
|
|
33
|
+
if (k) localBySyncId.set(k, r);
|
|
28
34
|
}
|
|
29
35
|
for (const r of remoteRecords) {
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|