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.
- package/README.md +67 -18
- package/admin/src/components/BulkTransferTab.jsx +880 -0
- package/admin/src/components/ConfigTab.jsx +25 -4
- package/admin/src/components/HelpTab.jsx +201 -15
- 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 +53 -7
- package/admin/src/pages/App/index.jsx +15 -1
- 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 +13 -4
- 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/bulk-transfer.js +141 -0
- package/server/src/controllers/config.js +48 -5
- package/server/src/controllers/index.js +4 -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 +28 -0
- package/server/src/services/bulk-transfer.js +837 -0
- package/server/src/services/config.js +18 -2
- package/server/src/services/index.js +4 -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 +323 -101
- package/server/src/utils/applier.js +120 -13
- package/server/src/utils/comparator.js +22 -6
- 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
|
|
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
|
|