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.
@@ -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: enabledTypes.map((ct) => ct.uid),
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 enabledTypes) {
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 diff = compareRecords(localRecords, remoteRecords, {
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
- // 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 });
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
- 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 });
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
- 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 });
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
- 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 });
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
- 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
- }
157
-
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 });
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 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 });
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
- 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 });
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
- 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 });
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
- 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 });
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
- 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 });
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
- 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
- }
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
- 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 });
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, fields);
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, fields);
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, fields);
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, fields);
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
+ });