strapi-content-sync-pro 1.0.0
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 +21 -0
- package/README.md +206 -0
- package/admin/src/components/ConfigTab.jsx +1038 -0
- package/admin/src/components/ContentTypesTab.jsx +160 -0
- package/admin/src/components/HelpTab.jsx +945 -0
- package/admin/src/components/LogsTab.jsx +136 -0
- package/admin/src/components/MediaTab.jsx +557 -0
- package/admin/src/components/SyncProfilesTab.jsx +715 -0
- package/admin/src/components/SyncTab.jsx +988 -0
- package/admin/src/index.js +31 -0
- package/admin/src/pages/App/index.jsx +129 -0
- package/admin/src/pluginId.js +3 -0
- package/package.json +84 -0
- package/server/src/bootstrap.js +151 -0
- package/server/src/config/index.js +5 -0
- package/server/src/content-types/index.js +7 -0
- package/server/src/content-types/sync-log/schema.json +24 -0
- package/server/src/controllers/alerts.js +59 -0
- package/server/src/controllers/config.js +292 -0
- package/server/src/controllers/content-type-discovery.js +9 -0
- package/server/src/controllers/dependencies.js +109 -0
- package/server/src/controllers/index.js +29 -0
- package/server/src/controllers/ping.js +7 -0
- package/server/src/controllers/sync-config.js +26 -0
- package/server/src/controllers/sync-enforcement.js +323 -0
- package/server/src/controllers/sync-execution.js +134 -0
- package/server/src/controllers/sync-log.js +18 -0
- package/server/src/controllers/sync-media.js +158 -0
- package/server/src/controllers/sync-profiles.js +182 -0
- package/server/src/controllers/sync.js +31 -0
- package/server/src/destroy.js +7 -0
- package/server/src/index.js +21 -0
- package/server/src/middlewares/verify-signature.js +32 -0
- package/server/src/register.js +7 -0
- package/server/src/routes/index.js +111 -0
- package/server/src/services/alerts.js +437 -0
- package/server/src/services/config.js +68 -0
- package/server/src/services/content-type-discovery.js +41 -0
- package/server/src/services/dependency-resolver.js +284 -0
- package/server/src/services/index.js +30 -0
- package/server/src/services/ping.js +7 -0
- package/server/src/services/sync-config.js +45 -0
- package/server/src/services/sync-enforcement.js +362 -0
- package/server/src/services/sync-execution.js +541 -0
- package/server/src/services/sync-log.js +56 -0
- package/server/src/services/sync-media.js +963 -0
- package/server/src/services/sync-profiles.js +380 -0
- package/server/src/services/sync.js +248 -0
- package/server/src/utils/applier.js +89 -0
- package/server/src/utils/comparator.js +83 -0
- package/server/src/utils/fetcher.js +142 -0
- package/server/src/utils/hmac.js +37 -0
- package/server/src/utils/pagination.js +51 -0
- package/server/src/utils/sync-guard.js +29 -0
- package/server/src/utils/sync-id.js +16 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { generateSignature } = require('./hmac');
|
|
4
|
+
const { markAsRemoteUpdate } = require('./sync-guard');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Apply a record received from a remote instance to the local database.
|
|
8
|
+
*/
|
|
9
|
+
async function applyLocal(strapi, uid, record, fields) {
|
|
10
|
+
const data = filterFields(record, fields);
|
|
11
|
+
const syncId = record.syncId;
|
|
12
|
+
|
|
13
|
+
// Mark so the afterCreate/afterUpdate hook skips re-pushing
|
|
14
|
+
markAsRemoteUpdate(`${uid}:${syncId}`);
|
|
15
|
+
|
|
16
|
+
const existing = await strapi.documents(uid).findMany({
|
|
17
|
+
filters: { syncId },
|
|
18
|
+
limit: 1,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (existing && existing.length > 0) {
|
|
22
|
+
return strapi.documents(uid).update({
|
|
23
|
+
documentId: existing[0].documentId,
|
|
24
|
+
data,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
data.syncId = syncId;
|
|
29
|
+
return strapi.documents(uid).create({ data });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Push a local record to the remote instance via the plugin's /receive endpoint.
|
|
34
|
+
*/
|
|
35
|
+
async function applyRemote(remoteConfig, uid, record, fields) {
|
|
36
|
+
const { baseUrl, apiToken, sharedSecret } = remoteConfig;
|
|
37
|
+
const url = new URL('/strapi-content-sync-pro/receive', baseUrl);
|
|
38
|
+
|
|
39
|
+
const body = {
|
|
40
|
+
uid,
|
|
41
|
+
data: filterFields(record, fields),
|
|
42
|
+
syncId: record.syncId,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const timestamp = Date.now().toString();
|
|
46
|
+
const signature = generateSignature(body, sharedSecret, timestamp);
|
|
47
|
+
|
|
48
|
+
const response = await fetch(url.toString(), {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: {
|
|
51
|
+
Authorization: `Bearer ${apiToken}`,
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
'x-sync-signature': signature,
|
|
54
|
+
'x-sync-timestamp': timestamp,
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
const text = await response.text();
|
|
61
|
+
throw new Error(`Remote apply failed for ${uid}: ${response.status} – ${text}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return response.json();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Return only the requested fields from a record, stripping Strapi internals.
|
|
69
|
+
*/
|
|
70
|
+
function filterFields(record, fields) {
|
|
71
|
+
if (!fields || fields.length === 0) {
|
|
72
|
+
const {
|
|
73
|
+
id, documentId, createdAt, updatedAt, publishedAt,
|
|
74
|
+
createdBy, updatedBy, locale, localizations,
|
|
75
|
+
...data
|
|
76
|
+
} = record;
|
|
77
|
+
return data;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const data = {};
|
|
81
|
+
for (const field of fields) {
|
|
82
|
+
if (record[field] !== undefined) {
|
|
83
|
+
data[field] = record[field];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return data;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { applyLocal, applyRemote, filterFields };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compare local and remote record sets and return the required sync actions.
|
|
5
|
+
*
|
|
6
|
+
* @param {Array} localRecords
|
|
7
|
+
* @param {Array} remoteRecords
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {string} options.direction – "push" | "pull" | "both"
|
|
10
|
+
* @param {string} options.conflictStrategy – "latest" | "local_wins" | "remote_wins"
|
|
11
|
+
* @returns {{ toPush, toPull, toCreateRemote, toCreateLocal }}
|
|
12
|
+
*/
|
|
13
|
+
function compareRecords(localRecords, remoteRecords, options = {}) {
|
|
14
|
+
const { direction = 'both', conflictStrategy = 'latest' } = options;
|
|
15
|
+
|
|
16
|
+
const result = {
|
|
17
|
+
toPush: [],
|
|
18
|
+
toPull: [],
|
|
19
|
+
toCreateRemote: [],
|
|
20
|
+
toCreateLocal: [],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const localBySyncId = new Map();
|
|
24
|
+
const remoteBySyncId = new Map();
|
|
25
|
+
|
|
26
|
+
for (const r of localRecords) {
|
|
27
|
+
if (r.syncId) localBySyncId.set(r.syncId, r);
|
|
28
|
+
}
|
|
29
|
+
for (const r of remoteRecords) {
|
|
30
|
+
if (r.syncId) remoteBySyncId.set(r.syncId, r);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Records that exist on both sides
|
|
34
|
+
for (const [syncId, localRecord] of localBySyncId) {
|
|
35
|
+
const remoteRecord = remoteBySyncId.get(syncId);
|
|
36
|
+
|
|
37
|
+
if (remoteRecord) {
|
|
38
|
+
const winner = resolveConflict(localRecord, remoteRecord, conflictStrategy);
|
|
39
|
+
|
|
40
|
+
if (winner === 'local' && (direction === 'push' || direction === 'both')) {
|
|
41
|
+
result.toPush.push({ local: localRecord, remote: remoteRecord });
|
|
42
|
+
} else if (winner === 'remote' && (direction === 'pull' || direction === 'both')) {
|
|
43
|
+
result.toPull.push({ local: localRecord, remote: remoteRecord });
|
|
44
|
+
}
|
|
45
|
+
} else if (direction === 'push' || direction === 'both') {
|
|
46
|
+
result.toCreateRemote.push(localRecord);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Records that only exist on the remote
|
|
51
|
+
for (const [syncId] of remoteBySyncId) {
|
|
52
|
+
if (!localBySyncId.has(syncId)) {
|
|
53
|
+
if (direction === 'pull' || direction === 'both') {
|
|
54
|
+
result.toCreateLocal.push(remoteBySyncId.get(syncId));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Determine the winner of a conflict.
|
|
64
|
+
* @returns {'local' | 'remote' | 'equal'}
|
|
65
|
+
*/
|
|
66
|
+
function resolveConflict(localRecord, remoteRecord, strategy) {
|
|
67
|
+
const localTime = new Date(localRecord.updatedAt).getTime();
|
|
68
|
+
const remoteTime = new Date(remoteRecord.updatedAt).getTime();
|
|
69
|
+
|
|
70
|
+
if (localTime === remoteTime) return 'equal';
|
|
71
|
+
|
|
72
|
+
switch (strategy) {
|
|
73
|
+
case 'local_wins':
|
|
74
|
+
return 'local';
|
|
75
|
+
case 'remote_wins':
|
|
76
|
+
return 'remote';
|
|
77
|
+
case 'latest':
|
|
78
|
+
default:
|
|
79
|
+
return localTime > remoteTime ? 'local' : 'remote';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { compareRecords, resolveConflict };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { paginate, DEFAULT_PAGE_SIZE, normalizePageSize } = require('./pagination');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fetch ONE page of local records from the Strapi document service.
|
|
7
|
+
* Returns { records, hasMore, total }.
|
|
8
|
+
*/
|
|
9
|
+
async function fetchLocalPage(strapi, uid, { fields, lastSyncAt, page = 1, pageSize = DEFAULT_PAGE_SIZE } = {}) {
|
|
10
|
+
const size = normalizePageSize(pageSize);
|
|
11
|
+
const params = {
|
|
12
|
+
start: (page - 1) * size,
|
|
13
|
+
limit: size,
|
|
14
|
+
sort: 'updatedAt:asc',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
if (lastSyncAt) {
|
|
18
|
+
params.filters = { updatedAt: { $gt: lastSyncAt } };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (fields && fields.length > 0) {
|
|
22
|
+
params.fields = [...new Set([...fields, 'syncId', 'updatedAt'])];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const records = (await strapi.documents(uid).findMany(params)) || [];
|
|
26
|
+
return { records, hasMore: records.length === size };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fetch ONE page of remote records via the standard Strapi REST API.
|
|
31
|
+
* Returns { records, hasMore, total, pageCount }.
|
|
32
|
+
*/
|
|
33
|
+
async function fetchRemotePage(remoteConfig, uid, { fields, lastSyncAt, page = 1, pageSize = DEFAULT_PAGE_SIZE } = {}) {
|
|
34
|
+
const size = normalizePageSize(pageSize);
|
|
35
|
+
const { baseUrl, apiToken } = remoteConfig;
|
|
36
|
+
const pluralName = uidToPluralEndpoint(uid);
|
|
37
|
+
const url = new URL(`/api/${pluralName}`, baseUrl);
|
|
38
|
+
|
|
39
|
+
if (fields && fields.length > 0) {
|
|
40
|
+
const allFields = [...new Set([...fields, 'syncId', 'updatedAt'])];
|
|
41
|
+
allFields.forEach((f, i) => {
|
|
42
|
+
url.searchParams.set(`fields[${i}]`, f);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (lastSyncAt) {
|
|
47
|
+
url.searchParams.set('filters[updatedAt][$gt]', lastSyncAt);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
url.searchParams.set('pagination[page]', String(page));
|
|
51
|
+
url.searchParams.set('pagination[pageSize]', String(size));
|
|
52
|
+
url.searchParams.set('sort', 'updatedAt:asc');
|
|
53
|
+
|
|
54
|
+
const response = await fetch(url.toString(), {
|
|
55
|
+
method: 'GET',
|
|
56
|
+
headers: {
|
|
57
|
+
Authorization: `Bearer ${apiToken}`,
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
const text = await response.text();
|
|
64
|
+
throw new Error(`Remote fetch failed for ${uid}: ${response.status} – ${text}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const json = await response.json();
|
|
68
|
+
const records = json.data || [];
|
|
69
|
+
const meta = json.meta?.pagination;
|
|
70
|
+
const pageCount = meta?.pageCount;
|
|
71
|
+
const total = meta?.total;
|
|
72
|
+
const hasMore = pageCount ? page < pageCount : records.length === size;
|
|
73
|
+
|
|
74
|
+
return { records, hasMore, total, pageCount };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Async iterator over all local pages.
|
|
79
|
+
*/
|
|
80
|
+
function iterateLocalPages(strapi, uid, options = {}) {
|
|
81
|
+
return paginate(
|
|
82
|
+
(page, pageSize) => fetchLocalPage(strapi, uid, { ...options, page, pageSize }),
|
|
83
|
+
{ pageSize: options.pageSize }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Async iterator over all remote pages.
|
|
89
|
+
*/
|
|
90
|
+
function iterateRemotePages(remoteConfig, uid, options = {}) {
|
|
91
|
+
return paginate(
|
|
92
|
+
(page, pageSize) => fetchRemotePage(remoteConfig, uid, { ...options, page, pageSize }),
|
|
93
|
+
{ pageSize: options.pageSize }
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Back-compat: fetch ALL local records (aggregates pages). Prefer the
|
|
99
|
+
* iterator variant for large datasets.
|
|
100
|
+
*/
|
|
101
|
+
async function fetchLocalRecords(strapi, uid, options = {}) {
|
|
102
|
+
const out = [];
|
|
103
|
+
for await (const { records } of iterateLocalPages(strapi, uid, options)) {
|
|
104
|
+
out.push(...records);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Back-compat: fetch ALL remote records (aggregates pages). Prefer the
|
|
111
|
+
* iterator variant for large datasets.
|
|
112
|
+
*/
|
|
113
|
+
async function fetchRemoteRecords(remoteConfig, uid, options = {}) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for await (const { records } of iterateRemotePages(remoteConfig, uid, options)) {
|
|
116
|
+
out.push(...records);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Convert a content-type UID to its plural REST endpoint name.
|
|
123
|
+
* e.g. "api::product.product" → "products"
|
|
124
|
+
*/
|
|
125
|
+
function uidToPluralEndpoint(uid) {
|
|
126
|
+
const parts = uid.split('.');
|
|
127
|
+
const modelName = parts[parts.length - 1];
|
|
128
|
+
if (modelName.endsWith('s')) return modelName;
|
|
129
|
+
if (modelName.endsWith('y')) return modelName.slice(0, -1) + 'ies';
|
|
130
|
+
return modelName + 's';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
fetchLocalRecords,
|
|
135
|
+
fetchRemoteRecords,
|
|
136
|
+
fetchLocalPage,
|
|
137
|
+
fetchRemotePage,
|
|
138
|
+
iterateLocalPages,
|
|
139
|
+
iterateRemotePages,
|
|
140
|
+
uidToPluralEndpoint,
|
|
141
|
+
};
|
|
142
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate an HMAC-SHA256 signature for a payload.
|
|
7
|
+
*/
|
|
8
|
+
function generateSignature(payload, secret, timestamp) {
|
|
9
|
+
const message = `${timestamp}.${typeof payload === 'string' ? payload : JSON.stringify(payload)}`;
|
|
10
|
+
return crypto.createHmac('sha256', secret).update(message).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Verify an HMAC-SHA256 signature.
|
|
15
|
+
* Rejects if timestamp is older than toleranceMs (default 5 minutes).
|
|
16
|
+
*/
|
|
17
|
+
function verifySignature(payload, secret, signature, timestamp, toleranceMs = 300000) {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const ts = parseInt(timestamp, 10);
|
|
20
|
+
|
|
21
|
+
if (isNaN(ts) || Math.abs(now - ts) > toleranceMs) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const expected = generateSignature(payload, secret, timestamp);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
return crypto.timingSafeEqual(
|
|
29
|
+
Buffer.from(expected, 'hex'),
|
|
30
|
+
Buffer.from(signature, 'hex')
|
|
31
|
+
);
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { generateSignature, verifySignature };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generic pagination helpers used by both the local Document Service path
|
|
5
|
+
* and the remote REST API path. Keeping these centralized means the sync
|
|
6
|
+
* engine can process arbitrarily large content types in bounded memory.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
10
|
+
const MIN_PAGE_SIZE = 1;
|
|
11
|
+
const MAX_PAGE_SIZE = 5000;
|
|
12
|
+
|
|
13
|
+
function normalizePageSize(size) {
|
|
14
|
+
const n = Number(size);
|
|
15
|
+
if (!Number.isFinite(n)) return DEFAULT_PAGE_SIZE;
|
|
16
|
+
return Math.max(MIN_PAGE_SIZE, Math.min(MAX_PAGE_SIZE, Math.floor(n)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Walk a paginated source page-by-page.
|
|
21
|
+
*
|
|
22
|
+
* fetchPage(page, pageSize) -> { records, hasMore, total? }
|
|
23
|
+
*
|
|
24
|
+
* The helper yields each page's records so callers can process/apply them
|
|
25
|
+
* without ever holding the full result set in memory.
|
|
26
|
+
*/
|
|
27
|
+
async function* paginate(fetchPage, { pageSize = DEFAULT_PAGE_SIZE, maxPages } = {}) {
|
|
28
|
+
const size = normalizePageSize(pageSize);
|
|
29
|
+
let page = 1;
|
|
30
|
+
while (true) {
|
|
31
|
+
const result = await fetchPage(page, size);
|
|
32
|
+
const records = Array.isArray(result) ? result : (result?.records || []);
|
|
33
|
+
const hasMore = Array.isArray(result)
|
|
34
|
+
? records.length === size
|
|
35
|
+
: !!result?.hasMore;
|
|
36
|
+
|
|
37
|
+
yield { page, pageSize: size, records, total: result?.total };
|
|
38
|
+
|
|
39
|
+
if (!hasMore || records.length === 0) break;
|
|
40
|
+
if (maxPages && page >= maxPages) break;
|
|
41
|
+
page += 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
DEFAULT_PAGE_SIZE,
|
|
47
|
+
MIN_PAGE_SIZE,
|
|
48
|
+
MAX_PAGE_SIZE,
|
|
49
|
+
normalizePageSize,
|
|
50
|
+
paginate,
|
|
51
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory guard to prevent infinite sync loops.
|
|
5
|
+
*
|
|
6
|
+
* Before applying a record received from a remote instance we call
|
|
7
|
+
* markAsRemoteUpdate(key). The afterCreate / afterUpdate lifecycle
|
|
8
|
+
* hook then calls isRemoteUpdate(key) — if it returns true the hook
|
|
9
|
+
* skips pushing the record back to the remote.
|
|
10
|
+
*/
|
|
11
|
+
const _active = new Set();
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
markAsRemoteUpdate(key) {
|
|
15
|
+
_active.add(key);
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
isRemoteUpdate(key) {
|
|
19
|
+
if (_active.has(key)) {
|
|
20
|
+
_active.delete(key);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
clear() {
|
|
27
|
+
_active.clear();
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
function generateSyncId() {
|
|
6
|
+
return crypto.randomUUID();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function ensureSyncId(data) {
|
|
10
|
+
if (!data.syncId) {
|
|
11
|
+
data.syncId = generateSyncId();
|
|
12
|
+
}
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { generateSyncId, ensureSyncId };
|