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