strapi-content-sync-pro 1.0.2 → 1.0.4

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 (36) hide show
  1. package/README.md +67 -18
  2. package/admin/src/components/BulkTransferTab.jsx +880 -0
  3. package/admin/src/components/ConfigTab.jsx +25 -4
  4. package/admin/src/components/HelpTab.jsx +201 -15
  5. package/admin/src/components/MediaTab.jsx +7 -0
  6. package/admin/src/components/StatsTab.jsx +470 -0
  7. package/admin/src/components/SyncProfilesTab.jsx +63 -5
  8. package/admin/src/components/SyncTab.jsx +53 -7
  9. package/admin/src/pages/App/index.jsx +15 -1
  10. package/docs/clipchamp-screen-recording-script.md +0 -0
  11. package/docs/production-readiness-status.md +34 -0
  12. package/docs/production-readiness-test-matrix.md +151 -0
  13. package/docs/test-environments-setup-legacy.txt +60 -0
  14. package/package.json +13 -4
  15. package/server/src/content-types/index.js +2 -0
  16. package/server/src/content-types/sync-run-report/schema.json +26 -0
  17. package/server/src/controllers/bulk-transfer.js +141 -0
  18. package/server/src/controllers/config.js +48 -5
  19. package/server/src/controllers/index.js +4 -0
  20. package/server/src/controllers/sync-log.js +6 -0
  21. package/server/src/controllers/sync-media.js +19 -0
  22. package/server/src/controllers/sync-stats.js +51 -0
  23. package/server/src/controllers/sync.js +9 -3
  24. package/server/src/routes/index.js +28 -0
  25. package/server/src/services/bulk-transfer.js +837 -0
  26. package/server/src/services/config.js +18 -2
  27. package/server/src/services/index.js +4 -0
  28. package/server/src/services/sync-execution.js +102 -5
  29. package/server/src/services/sync-log.js +36 -0
  30. package/server/src/services/sync-media.js +224 -1
  31. package/server/src/services/sync-profiles.js +92 -4
  32. package/server/src/services/sync-stats.js +353 -0
  33. package/server/src/services/sync.js +323 -101
  34. package/server/src/utils/applier.js +120 -13
  35. package/server/src/utils/comparator.js +22 -6
  36. package/server/src/utils/fetcher.js +11 -2
@@ -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