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
|
@@ -38,6 +38,29 @@ module.exports = ({ strapi }) => {
|
|
|
38
38
|
const VALID_DIRECTIONS = ['push', 'pull', 'both', 'none'];
|
|
39
39
|
const VALID_CONFLICT_STRATEGIES = ['latest', 'local_wins', 'remote_wins'];
|
|
40
40
|
|
|
41
|
+
async function getSyncMode() {
|
|
42
|
+
const configService = strapi.plugin('strapi-content-sync-pro').service('config');
|
|
43
|
+
const config = await configService.getConfig({ safe: false });
|
|
44
|
+
return config?.syncMode || 'paired';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeProfileForMode(profile, syncMode) {
|
|
48
|
+
if (syncMode !== 'single_side') return profile;
|
|
49
|
+
|
|
50
|
+
const next = { ...profile };
|
|
51
|
+
next.direction = 'pull';
|
|
52
|
+
|
|
53
|
+
if (!next.isSimple && Array.isArray(next.fieldPolicies)) {
|
|
54
|
+
next.fieldPolicies = next.fieldPolicies.map((fp) => {
|
|
55
|
+
if (fp.direction === 'push') return { ...fp, direction: 'pull' };
|
|
56
|
+
if (fp.direction === 'both') return { ...fp, direction: 'pull' };
|
|
57
|
+
return fp;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return next;
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
return {
|
|
42
65
|
/**
|
|
43
66
|
* Get all sync profiles
|
|
@@ -45,7 +68,33 @@ module.exports = ({ strapi }) => {
|
|
|
45
68
|
async getProfiles() {
|
|
46
69
|
const store = getStore();
|
|
47
70
|
const data = await store.get({ key: STORE_KEY });
|
|
48
|
-
|
|
71
|
+
const profiles = data || [];
|
|
72
|
+
const syncMode = await getSyncMode();
|
|
73
|
+
if (syncMode !== 'single_side') return profiles;
|
|
74
|
+
|
|
75
|
+
let changed = false;
|
|
76
|
+
const normalized = profiles.map((p) => {
|
|
77
|
+
if (p.direction === 'pull') return p;
|
|
78
|
+
changed = true;
|
|
79
|
+
return {
|
|
80
|
+
...p,
|
|
81
|
+
direction: 'pull',
|
|
82
|
+
syncDeletions: !!p.syncDeletions,
|
|
83
|
+
fieldPolicies: Array.isArray(p.fieldPolicies)
|
|
84
|
+
? p.fieldPolicies.map((fp) => ({
|
|
85
|
+
...fp,
|
|
86
|
+
direction: fp.direction === 'none' ? 'none' : 'pull',
|
|
87
|
+
}))
|
|
88
|
+
: p.fieldPolicies,
|
|
89
|
+
updatedAt: new Date().toISOString(),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (changed) {
|
|
94
|
+
await store.set({ key: STORE_KEY, value: normalized });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return normalized;
|
|
49
98
|
},
|
|
50
99
|
|
|
51
100
|
/**
|
|
@@ -53,7 +102,10 @@ module.exports = ({ strapi }) => {
|
|
|
53
102
|
*/
|
|
54
103
|
async getProfile(id) {
|
|
55
104
|
const profiles = await this.getProfiles();
|
|
56
|
-
|
|
105
|
+
const profile = profiles.find((p) => p.id === id) || null;
|
|
106
|
+
if (!profile) return null;
|
|
107
|
+
const syncMode = await getSyncMode();
|
|
108
|
+
return normalizeProfileForMode(profile, syncMode);
|
|
57
109
|
},
|
|
58
110
|
|
|
59
111
|
/**
|
|
@@ -61,7 +113,11 @@ module.exports = ({ strapi }) => {
|
|
|
61
113
|
*/
|
|
62
114
|
async getActiveProfileForContentType(contentTypeUid) {
|
|
63
115
|
const profiles = await this.getProfiles();
|
|
64
|
-
|
|
116
|
+
const active = profiles.find((p) => p.contentType === contentTypeUid && p.isActive) || null;
|
|
117
|
+
if (!active) return null;
|
|
118
|
+
|
|
119
|
+
const syncMode = await getSyncMode();
|
|
120
|
+
return normalizeProfileForMode(active, syncMode);
|
|
65
121
|
},
|
|
66
122
|
|
|
67
123
|
/**
|
|
@@ -69,7 +125,10 @@ module.exports = ({ strapi }) => {
|
|
|
69
125
|
*/
|
|
70
126
|
async getProfilesForContentType(contentTypeUid) {
|
|
71
127
|
const profiles = await this.getProfiles();
|
|
72
|
-
|
|
128
|
+
const syncMode = await getSyncMode();
|
|
129
|
+
return profiles
|
|
130
|
+
.filter((p) => p.contentType === contentTypeUid)
|
|
131
|
+
.map((p) => normalizeProfileForMode(p, syncMode));
|
|
73
132
|
},
|
|
74
133
|
|
|
75
134
|
/**
|
|
@@ -91,6 +150,7 @@ module.exports = ({ strapi }) => {
|
|
|
91
150
|
contentType: contentTypeUid,
|
|
92
151
|
direction: 'push',
|
|
93
152
|
conflictStrategy: 'local_wins',
|
|
153
|
+
syncDeletions: false,
|
|
94
154
|
isActive: false,
|
|
95
155
|
isSimple: true,
|
|
96
156
|
fieldPolicies: [],
|
|
@@ -100,6 +160,7 @@ module.exports = ({ strapi }) => {
|
|
|
100
160
|
contentType: contentTypeUid,
|
|
101
161
|
direction: 'pull',
|
|
102
162
|
conflictStrategy: 'remote_wins',
|
|
163
|
+
syncDeletions: false,
|
|
103
164
|
isActive: false,
|
|
104
165
|
isSimple: true,
|
|
105
166
|
fieldPolicies: [],
|
|
@@ -109,6 +170,7 @@ module.exports = ({ strapi }) => {
|
|
|
109
170
|
contentType: contentTypeUid,
|
|
110
171
|
direction: 'both',
|
|
111
172
|
conflictStrategy: 'latest',
|
|
173
|
+
syncDeletions: false,
|
|
112
174
|
isActive: true, // Default active profile
|
|
113
175
|
isSimple: true,
|
|
114
176
|
fieldPolicies: [],
|
|
@@ -166,6 +228,7 @@ module.exports = ({ strapi }) => {
|
|
|
166
228
|
contentType: profileData.contentType,
|
|
167
229
|
direction: profileData.direction || 'both',
|
|
168
230
|
conflictStrategy: profileData.conflictStrategy || 'latest',
|
|
231
|
+
syncDeletions: !!profileData.syncDeletions,
|
|
169
232
|
isActive: profileData.isActive || false,
|
|
170
233
|
isSimple: profileData.isSimple !== false, // Default to simple mode
|
|
171
234
|
fieldPolicies: (profileData.fieldPolicies || []).map((fp) => ({
|
|
@@ -176,6 +239,17 @@ module.exports = ({ strapi }) => {
|
|
|
176
239
|
updatedAt: new Date().toISOString(),
|
|
177
240
|
};
|
|
178
241
|
|
|
242
|
+
const syncMode = await getSyncMode();
|
|
243
|
+
if (syncMode === 'single_side') {
|
|
244
|
+
newProfile.direction = 'pull';
|
|
245
|
+
if (!newProfile.isSimple && Array.isArray(newProfile.fieldPolicies)) {
|
|
246
|
+
newProfile.fieldPolicies = newProfile.fieldPolicies.map((fp) => ({
|
|
247
|
+
...fp,
|
|
248
|
+
direction: fp.direction === 'none' ? 'none' : 'pull',
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
179
253
|
// If this profile is set as active, deactivate others for same content type
|
|
180
254
|
if (newProfile.isActive) {
|
|
181
255
|
profiles.forEach((p) => {
|
|
@@ -238,11 +312,17 @@ module.exports = ({ strapi }) => {
|
|
|
238
312
|
const updatedProfile = {
|
|
239
313
|
...profiles[index],
|
|
240
314
|
...updates,
|
|
315
|
+
syncDeletions: updates.syncDeletions !== undefined ? !!updates.syncDeletions : profiles[index].syncDeletions,
|
|
241
316
|
id: profiles[index].id, // prevent id change
|
|
242
317
|
createdAt: profiles[index].createdAt, // preserve creation date
|
|
243
318
|
updatedAt: new Date().toISOString(),
|
|
244
319
|
};
|
|
245
320
|
|
|
321
|
+
const syncMode = await getSyncMode();
|
|
322
|
+
if (syncMode === 'single_side') {
|
|
323
|
+
updatedProfile.direction = 'pull';
|
|
324
|
+
}
|
|
325
|
+
|
|
246
326
|
if (updates.fieldPolicies) {
|
|
247
327
|
updatedProfile.fieldPolicies = updates.fieldPolicies.map((fp) => ({
|
|
248
328
|
field: fp.field,
|
|
@@ -250,6 +330,13 @@ module.exports = ({ strapi }) => {
|
|
|
250
330
|
}));
|
|
251
331
|
}
|
|
252
332
|
|
|
333
|
+
if (syncMode === 'single_side' && !updatedProfile.isSimple && Array.isArray(updatedProfile.fieldPolicies)) {
|
|
334
|
+
updatedProfile.fieldPolicies = updatedProfile.fieldPolicies.map((fp) => ({
|
|
335
|
+
...fp,
|
|
336
|
+
direction: fp.direction === 'none' ? 'none' : 'pull',
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
|
|
253
340
|
profiles[index] = updatedProfile;
|
|
254
341
|
await store.set({ key: STORE_KEY, value: profiles });
|
|
255
342
|
|
|
@@ -305,6 +392,7 @@ module.exports = ({ strapi }) => {
|
|
|
305
392
|
return this.createProfile({
|
|
306
393
|
...presetConfig,
|
|
307
394
|
contentType: contentTypeUid,
|
|
395
|
+
syncDeletions: false,
|
|
308
396
|
isSimple: true,
|
|
309
397
|
isActive: false,
|
|
310
398
|
fieldPolicies: [],
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { uidToPluralEndpoint } = require('../utils/fetcher');
|
|
4
|
+
|
|
5
|
+
const RUN_REPORT_UID = 'plugin::strapi-content-sync-pro.sync-run-report';
|
|
6
|
+
|
|
7
|
+
module.exports = ({ strapi }) => {
|
|
8
|
+
function plugin() {
|
|
9
|
+
return strapi.plugin('strapi-content-sync-pro');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Strapi upload's polymorphic join table name differs across versions
|
|
13
|
+
// (`files_related_morphs` on older builds, `files_related_mph` on v5).
|
|
14
|
+
// Resolve it from ORM metadata so stats work on both.
|
|
15
|
+
let _morphTableCache = null;
|
|
16
|
+
function resolveMorphTable() {
|
|
17
|
+
if (_morphTableCache) return _morphTableCache;
|
|
18
|
+
const candidates = [];
|
|
19
|
+
try {
|
|
20
|
+
const attr = strapi.db?.metadata?.get?.('plugin::upload.file')?.attributes?.related;
|
|
21
|
+
if (attr?.joinTable?.name) candidates.push(attr.joinTable.name);
|
|
22
|
+
if (attr?.pivotTable) candidates.push(attr.pivotTable);
|
|
23
|
+
} catch {
|
|
24
|
+
// ignore — fall back below
|
|
25
|
+
}
|
|
26
|
+
candidates.push('files_related_mph', 'files_related_morphs');
|
|
27
|
+
_morphTableCache = candidates.find((n) => !!n) || 'files_related_mph';
|
|
28
|
+
return _morphTableCache;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function getEnabledContentTypes() {
|
|
32
|
+
const syncConfig = await plugin().service('syncConfig').getSyncConfig();
|
|
33
|
+
return (syncConfig.contentTypes || []).filter((ct) => ct.enabled).map((ct) => ct.uid);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function getMediaStatsLocal() {
|
|
37
|
+
const [count, newest] = await Promise.all([
|
|
38
|
+
strapi.db.query('plugin::upload.file').count(),
|
|
39
|
+
strapi.db.query('plugin::upload.file').findMany({
|
|
40
|
+
orderBy: { updatedAt: 'desc' },
|
|
41
|
+
limit: 1,
|
|
42
|
+
select: ['updatedAt'],
|
|
43
|
+
}),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const morphTable = resolveMorphTable();
|
|
47
|
+
const [morphCount, newestMorph] = await Promise.all([
|
|
48
|
+
strapi.db.connection(morphTable).count({ id: 'id' }).first().then((r) => Number(r?.id || 0)).catch(() => 0),
|
|
49
|
+
strapi.db.connection(morphTable).orderBy('id', 'desc').first().catch(() => null),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
fileCount: Number(count || 0),
|
|
54
|
+
fileNewestUpdatedAt: newest?.[0]?.updatedAt || null,
|
|
55
|
+
morphCount: Number(morphCount || 0),
|
|
56
|
+
morphNewestUpdatedAt: newestMorph?.created_at || newestMorph?.updated_at || null,
|
|
57
|
+
error: null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function getMediaStatsRemote(remoteConfig) {
|
|
62
|
+
const { baseUrl, apiToken } = remoteConfig || {};
|
|
63
|
+
if (!baseUrl || !apiToken) {
|
|
64
|
+
return {
|
|
65
|
+
fileCount: null,
|
|
66
|
+
fileNewestUpdatedAt: null,
|
|
67
|
+
morphCount: null,
|
|
68
|
+
morphNewestUpdatedAt: null,
|
|
69
|
+
error: 'Remote server is not configured',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const countUrl = new URL('/api/upload/files', baseUrl);
|
|
75
|
+
countUrl.searchParams.set('pagination[page]', '1');
|
|
76
|
+
countUrl.searchParams.set('pagination[pageSize]', '1');
|
|
77
|
+
countUrl.searchParams.set('fields[0]', 'updatedAt');
|
|
78
|
+
const countRes = await fetch(countUrl.toString(), {
|
|
79
|
+
method: 'GET',
|
|
80
|
+
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
|
81
|
+
});
|
|
82
|
+
if (!countRes.ok) {
|
|
83
|
+
return { fileCount: null, fileNewestUpdatedAt: null, morphCount: null, morphNewestUpdatedAt: null, error: `Remote media fetch failed (${countRes.status})` };
|
|
84
|
+
}
|
|
85
|
+
const countBody = await countRes.json();
|
|
86
|
+
const fileCount = countBody?.meta?.pagination?.total ?? null;
|
|
87
|
+
|
|
88
|
+
const newestUrl = new URL('/api/upload/files', baseUrl);
|
|
89
|
+
newestUrl.searchParams.set('pagination[page]', '1');
|
|
90
|
+
newestUrl.searchParams.set('pagination[pageSize]', '1');
|
|
91
|
+
newestUrl.searchParams.set('sort', 'updatedAt:desc');
|
|
92
|
+
newestUrl.searchParams.set('fields[0]', 'updatedAt');
|
|
93
|
+
const newestRes = await fetch(newestUrl.toString(), {
|
|
94
|
+
method: 'GET',
|
|
95
|
+
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
|
96
|
+
});
|
|
97
|
+
if (!newestRes.ok) {
|
|
98
|
+
return { fileCount, fileNewestUpdatedAt: null, morphCount: null, morphNewestUpdatedAt: null, error: `Remote media newest fetch failed (${newestRes.status})` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const newestBody = await newestRes.json();
|
|
102
|
+
const fileNewestUpdatedAt = newestBody?.data?.[0]?.updatedAt || null;
|
|
103
|
+
|
|
104
|
+
// Morph rows are internal DB relations; not exposed by core upload REST.
|
|
105
|
+
return {
|
|
106
|
+
fileCount,
|
|
107
|
+
fileNewestUpdatedAt,
|
|
108
|
+
morphCount: null,
|
|
109
|
+
morphNewestUpdatedAt: null,
|
|
110
|
+
error: null,
|
|
111
|
+
};
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return { fileCount: null, fileNewestUpdatedAt: null, morphCount: null, morphNewestUpdatedAt: null, error: err.message };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function fetchRemoteStats(remoteConfig, uid) {
|
|
118
|
+
const { baseUrl, apiToken } = remoteConfig || {};
|
|
119
|
+
if (!baseUrl || !apiToken) {
|
|
120
|
+
return { count: null, newestUpdatedAt: null, error: 'Remote server is not configured' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const endpoint = uidToPluralEndpoint(uid);
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const countUrl = new URL(`/api/${endpoint}`, baseUrl);
|
|
127
|
+
countUrl.searchParams.set('pagination[page]', '1');
|
|
128
|
+
countUrl.searchParams.set('pagination[pageSize]', '1');
|
|
129
|
+
countUrl.searchParams.set('fields[0]', 'updatedAt');
|
|
130
|
+
const countRes = await fetch(countUrl.toString(), {
|
|
131
|
+
method: 'GET',
|
|
132
|
+
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
|
133
|
+
});
|
|
134
|
+
if (!countRes.ok) {
|
|
135
|
+
return { count: null, newestUpdatedAt: null, error: `Remote count fetch failed (${countRes.status})` };
|
|
136
|
+
}
|
|
137
|
+
const countBody = await countRes.json();
|
|
138
|
+
const count = countBody?.meta?.pagination?.total ?? null;
|
|
139
|
+
|
|
140
|
+
const newestUrl = new URL(`/api/${endpoint}`, baseUrl);
|
|
141
|
+
newestUrl.searchParams.set('pagination[page]', '1');
|
|
142
|
+
newestUrl.searchParams.set('pagination[pageSize]', '1');
|
|
143
|
+
newestUrl.searchParams.set('sort', 'updatedAt:desc');
|
|
144
|
+
newestUrl.searchParams.set('fields[0]', 'updatedAt');
|
|
145
|
+
const newestRes = await fetch(newestUrl.toString(), {
|
|
146
|
+
method: 'GET',
|
|
147
|
+
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
|
148
|
+
});
|
|
149
|
+
if (!newestRes.ok) {
|
|
150
|
+
return { count, newestUpdatedAt: null, error: `Remote newest fetch failed (${newestRes.status})` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const newestBody = await newestRes.json();
|
|
154
|
+
const newestUpdatedAt = newestBody?.data?.[0]?.updatedAt || null;
|
|
155
|
+
return { count, newestUpdatedAt, error: null };
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return { count: null, newestUpdatedAt: null, error: err.message };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function whereNewest(localTs, remoteTs) {
|
|
162
|
+
if (!localTs && !remoteTs) return 'equal';
|
|
163
|
+
if (localTs && !remoteTs) return 'local';
|
|
164
|
+
if (!localTs && remoteTs) return 'remote';
|
|
165
|
+
const l = new Date(localTs).getTime();
|
|
166
|
+
const r = new Date(remoteTs).getTime();
|
|
167
|
+
if (l === r) return 'equal';
|
|
168
|
+
return l > r ? 'local' : 'remote';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
async collectSnapshot({ contentTypes } = {}) {
|
|
173
|
+
const configService = plugin().service('config');
|
|
174
|
+
const remoteConfig = await configService.getConfig({ safe: false });
|
|
175
|
+
const targets = Array.isArray(contentTypes) && contentTypes.length > 0
|
|
176
|
+
? contentTypes
|
|
177
|
+
: await getEnabledContentTypes();
|
|
178
|
+
|
|
179
|
+
const rows = [];
|
|
180
|
+
for (const uid of targets) {
|
|
181
|
+
let localCount = null;
|
|
182
|
+
let localNewestUpdatedAt = null;
|
|
183
|
+
let localError = null;
|
|
184
|
+
try {
|
|
185
|
+
localCount = await strapi.documents(uid).count({});
|
|
186
|
+
const newest = await strapi.documents(uid).findMany({
|
|
187
|
+
sort: { updatedAt: 'desc' },
|
|
188
|
+
limit: 1,
|
|
189
|
+
fields: ['updatedAt'],
|
|
190
|
+
});
|
|
191
|
+
localNewestUpdatedAt = newest?.[0]?.updatedAt || null;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
localError = err.message;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const remote = await fetchRemoteStats(remoteConfig, uid);
|
|
197
|
+
rows.push({
|
|
198
|
+
uid,
|
|
199
|
+
type: 'content',
|
|
200
|
+
localCount,
|
|
201
|
+
remoteCount: remote.count,
|
|
202
|
+
localNewestUpdatedAt,
|
|
203
|
+
remoteNewestUpdatedAt: remote.newestUpdatedAt,
|
|
204
|
+
newestSide: whereNewest(localNewestUpdatedAt, remote.newestUpdatedAt),
|
|
205
|
+
localError,
|
|
206
|
+
remoteError: remote.error,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const localMedia = await getMediaStatsLocal();
|
|
211
|
+
const remoteMedia = await getMediaStatsRemote(remoteConfig);
|
|
212
|
+
rows.push({
|
|
213
|
+
uid: 'plugin::upload.file',
|
|
214
|
+
type: 'media_file',
|
|
215
|
+
localCount: localMedia.fileCount,
|
|
216
|
+
remoteCount: remoteMedia.fileCount,
|
|
217
|
+
localNewestUpdatedAt: localMedia.fileNewestUpdatedAt,
|
|
218
|
+
remoteNewestUpdatedAt: remoteMedia.fileNewestUpdatedAt,
|
|
219
|
+
newestSide: whereNewest(localMedia.fileNewestUpdatedAt, remoteMedia.fileNewestUpdatedAt),
|
|
220
|
+
localError: localMedia.error,
|
|
221
|
+
remoteError: remoteMedia.error,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
rows.push({
|
|
225
|
+
uid: 'upload.morph',
|
|
226
|
+
type: 'media_morph',
|
|
227
|
+
localCount: localMedia.morphCount,
|
|
228
|
+
remoteCount: remoteMedia.morphCount,
|
|
229
|
+
localNewestUpdatedAt: localMedia.morphNewestUpdatedAt,
|
|
230
|
+
remoteNewestUpdatedAt: remoteMedia.morphNewestUpdatedAt,
|
|
231
|
+
newestSide: whereNewest(localMedia.morphNewestUpdatedAt, remoteMedia.morphNewestUpdatedAt),
|
|
232
|
+
localError: localMedia.error,
|
|
233
|
+
remoteError: remoteMedia.morphCount === null ? 'Remote morph stats are unavailable via public API' : remoteMedia.error,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
generatedAt: new Date().toISOString(),
|
|
238
|
+
contentTypes: targets,
|
|
239
|
+
rows,
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async createRunReport({ runType = 'content', trigger = 'manual', contentTypes = [], beforeStats = null }) {
|
|
244
|
+
const startedAt = new Date().toISOString();
|
|
245
|
+
const baseBefore = beforeStats || await this.collectSnapshot({ contentTypes });
|
|
246
|
+
const doc = await strapi.documents(RUN_REPORT_UID).create({
|
|
247
|
+
data: {
|
|
248
|
+
runType,
|
|
249
|
+
trigger,
|
|
250
|
+
status: 'running',
|
|
251
|
+
startedAt,
|
|
252
|
+
completedAt: null,
|
|
253
|
+
contentTypes: baseBefore.contentTypes,
|
|
254
|
+
beforeStats: baseBefore,
|
|
255
|
+
afterStats: null,
|
|
256
|
+
summary: null,
|
|
257
|
+
error: null,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
return { reportId: doc.documentId, startedAt, beforeStats: baseBefore };
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async completeRunReport(reportId, { status = 'success', summary = null, error = null } = {}) {
|
|
264
|
+
if (!reportId) return null;
|
|
265
|
+
const report = await strapi.documents(RUN_REPORT_UID).findFirst({
|
|
266
|
+
filters: { documentId: reportId },
|
|
267
|
+
fields: ['documentId'],
|
|
268
|
+
});
|
|
269
|
+
if (!report?.documentId) return null;
|
|
270
|
+
|
|
271
|
+
const before = await strapi.documents(RUN_REPORT_UID).findFirst({
|
|
272
|
+
filters: { documentId: reportId },
|
|
273
|
+
fields: ['contentTypes'],
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const afterStats = await this.collectSnapshot({ contentTypes: before?.contentTypes || [] });
|
|
277
|
+
await strapi.documents(RUN_REPORT_UID).update({
|
|
278
|
+
documentId: report.documentId,
|
|
279
|
+
data: {
|
|
280
|
+
status,
|
|
281
|
+
completedAt: new Date().toISOString(),
|
|
282
|
+
afterStats,
|
|
283
|
+
summary: summary || null,
|
|
284
|
+
error: error || null,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
return { reportId: report.documentId, status };
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
async getLatestSnapshot() {
|
|
291
|
+
return this.collectSnapshot({});
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
async getReports({ page = 1, pageSize = 10 } = {}) {
|
|
295
|
+
const start = (page - 1) * pageSize;
|
|
296
|
+
const [data, total] = await Promise.all([
|
|
297
|
+
strapi.documents(RUN_REPORT_UID).findMany({
|
|
298
|
+
sort: { createdAt: 'desc' },
|
|
299
|
+
start,
|
|
300
|
+
limit: pageSize,
|
|
301
|
+
}),
|
|
302
|
+
strapi.documents(RUN_REPORT_UID).count(),
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
data,
|
|
307
|
+
meta: {
|
|
308
|
+
pagination: {
|
|
309
|
+
page,
|
|
310
|
+
pageSize,
|
|
311
|
+
pageCount: Math.ceil(total / pageSize),
|
|
312
|
+
total,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
async clearReports() {
|
|
319
|
+
const existing = await strapi.documents(RUN_REPORT_UID).findMany({
|
|
320
|
+
fields: ['documentId'],
|
|
321
|
+
sort: { createdAt: 'desc' },
|
|
322
|
+
limit: 10000,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
for (const report of existing) {
|
|
326
|
+
if (!report?.documentId) continue;
|
|
327
|
+
await strapi.documents(RUN_REPORT_UID).delete({ documentId: report.documentId });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return { deleted: existing.length };
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
async applyRetention({ maxReports = 200 } = {}) {
|
|
334
|
+
const safeMax = Math.max(10, Number(maxReports) || 200);
|
|
335
|
+
const total = await strapi.documents(RUN_REPORT_UID).count();
|
|
336
|
+
if (total <= safeMax) return { pruned: 0, remaining: total };
|
|
337
|
+
|
|
338
|
+
const excess = total - safeMax;
|
|
339
|
+
const oldReports = await strapi.documents(RUN_REPORT_UID).findMany({
|
|
340
|
+
fields: ['documentId'],
|
|
341
|
+
sort: { createdAt: 'asc' },
|
|
342
|
+
limit: excess,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
for (const report of oldReports) {
|
|
346
|
+
if (!report?.documentId) continue;
|
|
347
|
+
await strapi.documents(RUN_REPORT_UID).delete({ documentId: report.documentId });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { pruned: oldReports.length, remaining: total - oldReports.length };
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
};
|