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
@@ -0,0 +1,837 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Bulk Transfer service
5
+ * -----------------------------------------------------------------------------
6
+ * Provides one-click "full pull" / "full push" between local and the configured
7
+ * remote across user-selected scopes:
8
+ * - content : every user-defined collection type (api::*)
9
+ * - media : every active media sync profile (files + morph links)
10
+ * - users : plugin::users-permissions.user
11
+ * - admins : admin::user
12
+ *
13
+ * A job is expanded into ordered chunks. Each chunk reuses an existing sync
14
+ * primitive (syncContentType or syncMedia.runProfile). The UI can either
15
+ * auto-run chunks to completion or stop after each chunk and wait for the
16
+ * user to advance.
17
+ *
18
+ * Job state is held in memory (single-process). Restarting the server
19
+ * cancels any running job.
20
+ */
21
+
22
+ const PLUGIN_ID = 'strapi-content-sync-pro';
23
+ const HISTORY_STORE_KEY = 'bulk-transfer-history';
24
+ const HISTORY_MAX = 25;
25
+
26
+ // Module-level in-memory job registry. Single active job at a time is enough
27
+ // for this workflow; additional jobs may be queued/tracked by id if needed.
28
+ const jobs = new Map();
29
+
30
+ let jobSeq = 1;
31
+
32
+ function newJobId() {
33
+ return `bulk-${Date.now()}-${jobSeq++}`;
34
+ }
35
+
36
+ function now() {
37
+ return new Date().toISOString();
38
+ }
39
+
40
+ module.exports = ({ strapi }) => {
41
+ function plugin() {
42
+ return strapi.plugin(PLUGIN_ID);
43
+ }
44
+
45
+ function getStore() {
46
+ return strapi.store({ type: 'plugin', name: PLUGIN_ID });
47
+ }
48
+
49
+ async function readHistory() {
50
+ try {
51
+ return (await getStore().get({ key: HISTORY_STORE_KEY })) || [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ // Serialize history writes. Multiple paths (auto-run loop, pause, status
58
+ // polling, resume) can mutate history concurrently; without a queue the
59
+ // classic read-modify-write pattern loses updates.
60
+ let historyWriteChain = Promise.resolve();
61
+ function queueHistoryWrite(task) {
62
+ const next = historyWriteChain.then(task, task);
63
+ // Swallow rejection on the chain so future tasks still run.
64
+ historyWriteChain = next.catch(() => {});
65
+ return next;
66
+ }
67
+
68
+ async function writeHistory(entries) {
69
+ try {
70
+ await getStore().set({ key: HISTORY_STORE_KEY, value: entries.slice(0, HISTORY_MAX) });
71
+ } catch (err) {
72
+ strapi.log?.warn?.(`[bulk-transfer] unable to persist history: ${err.message}`);
73
+ }
74
+ }
75
+
76
+ function snapshotForHistory(job) {
77
+ return {
78
+ id: job.id,
79
+ status: job.status,
80
+ direction: job.direction,
81
+ scopes: job.scopes,
82
+ syncDeletions: job.syncDeletions,
83
+ autoContinue: job.autoContinue,
84
+ conflictStrategy: job.conflictStrategy,
85
+ createdAt: job.createdAt,
86
+ startedAt: job.startedAt,
87
+ completedAt: job.completedAt,
88
+ pausedAt: job.pausedAt || null,
89
+ resumedAt: job.resumedAt || null,
90
+ resumedFrom: job.resumedFrom || null,
91
+ cursor: job.cursor,
92
+ total: job.chunks.length,
93
+ errors: job.errors,
94
+ chunks: job.chunks.map((c) => ({
95
+ index: c.index,
96
+ kind: c.kind,
97
+ uid: c.uid || null,
98
+ profileId: c.profileId || null,
99
+ label: c.label,
100
+ // Never persist transient 'running' — if the process is paused or
101
+ // dies mid-chunk, reloaded entry must indicate the chunk needs to
102
+ // be resumed (resumeFromHistory treats non-terminal chunks that way).
103
+ status: c.status === 'running' ? 'paused' : c.status,
104
+ selected: c.selected !== false,
105
+ page: c.page || 0,
106
+ pagesTotal: c.pagesTotal || null,
107
+ pushed: c.pushed || 0,
108
+ pulled: c.pulled || 0,
109
+ errors: c.errors || 0,
110
+ error: c.error || null,
111
+ warning: c.warning || null,
112
+ startedAt: c.startedAt || null,
113
+ completedAt: c.completedAt || null,
114
+ })),
115
+ };
116
+ }
117
+
118
+ function persistJobToHistory(job) {
119
+ // Serialize through the write queue so concurrent updaters (auto-run
120
+ // loop, pause/resume, status polling) don't clobber each other. We
121
+ // snapshot inside the queued task to match the in-memory state at
122
+ // serialization time.
123
+ return queueHistoryWrite(async () => {
124
+ const history = await readHistory();
125
+ const snap = snapshotForHistory(job);
126
+ const existingIdx = history.findIndex((h) => h.id === job.id);
127
+ if (existingIdx >= 0) history[existingIdx] = snap;
128
+ else history.unshift(snap);
129
+ await writeHistory(history);
130
+ });
131
+ }
132
+
133
+ function listSyncableContentTypeUids() {
134
+ const out = [];
135
+ for (const [uid, ct] of Object.entries(strapi.contentTypes || {})) {
136
+ if (!uid.startsWith('api::')) continue;
137
+ if (ct.kind !== 'collectionType') continue;
138
+ out.push(uid);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ function listMediaProfilesToRun() {
144
+ // Use the media service's own active-profile semantics by delegating
145
+ // to runActiveProfiles at execute time; here we just need chunk labels.
146
+ // We expand via getProfiles() to produce per-profile chunks.
147
+ const syncMedia = plugin().service('syncMedia');
148
+ if (!syncMedia?.getProfiles) return Promise.resolve([]);
149
+ return syncMedia.getProfiles();
150
+ }
151
+
152
+ async function buildPlan({ direction, scopes }) {
153
+ const chunks = [];
154
+
155
+ if (scopes.content) {
156
+ for (const uid of listSyncableContentTypeUids()) {
157
+ chunks.push({ kind: 'content', uid, label: uid });
158
+ }
159
+ }
160
+
161
+ if (scopes.users) {
162
+ const uid = 'plugin::users-permissions.user';
163
+ if (strapi.contentTypes?.[uid]) {
164
+ chunks.push({ kind: 'users', uid, label: uid });
165
+ }
166
+ }
167
+
168
+ if (scopes.admins) {
169
+ const uid = 'admin::user';
170
+ if (strapi.contentTypes?.[uid]) {
171
+ chunks.push({
172
+ kind: 'admins',
173
+ uid,
174
+ label: uid,
175
+ warning: 'admin::user transfer is best-effort; passwords/roles may not be portable.',
176
+ });
177
+ }
178
+ }
179
+
180
+ if (scopes.media) {
181
+ const profiles = await listMediaProfilesToRun();
182
+ const active = (profiles || []).filter((p) => p.active && p.strategy !== 'disabled');
183
+ if (active.length === 0) {
184
+ chunks.push({
185
+ kind: 'media',
186
+ profileId: null,
187
+ label: 'media:active',
188
+ warning: 'No active media profiles found. Activate one in the Media tab first.',
189
+ });
190
+ } else {
191
+ for (const p of active) {
192
+ chunks.push({ kind: 'media', profileId: p.id, label: `media:${p.name || p.id}` });
193
+ }
194
+ }
195
+ }
196
+
197
+ // Tag index on each chunk so the UI can track progress reliably.
198
+ chunks.forEach((c, i) => {
199
+ c.index = i;
200
+ c.status = 'pending';
201
+ c.selected = true;
202
+ });
203
+
204
+ return chunks;
205
+ }
206
+
207
+ async function runContentChunk(job, chunk) {
208
+ const syncService = plugin().service('sync');
209
+ const syntheticProfile = {
210
+ id: `bulk-${job.id}`,
211
+ name: `Bulk ${job.direction}`,
212
+ contentType: chunk.uid,
213
+ direction: job.direction, // 'pull' or 'push'
214
+ conflictStrategy: job.conflictStrategy || 'latest',
215
+ isSimple: true,
216
+ syncDeletions: !!job.syncDeletions,
217
+ };
218
+
219
+ // Initialize per-chunk page progress (resumable across pause cycles).
220
+ chunk.page = chunk.page || 0; // last completed page
221
+ chunk.pushed = chunk.pushed || 0;
222
+ chunk.pulled = chunk.pulled || 0;
223
+ chunk.errors = chunk.errors || 0;
224
+ chunk.pageSize = chunk.pageSize || job.pageSize || null;
225
+ chunk.pagesTotal = chunk.pagesTotal || null; // populated after first page
226
+
227
+ let hasMore = true;
228
+ while (hasMore) {
229
+ if (job.status !== 'running') {
230
+ // Paused or cancelled: stop between pages so next resume picks up.
231
+ return {
232
+ paused: job.status === 'paused',
233
+ cancelled: job.status === 'cancelled',
234
+ page: chunk.page,
235
+ pagesTotal: chunk.pagesTotal,
236
+ pushed: chunk.pushed,
237
+ pulled: chunk.pulled,
238
+ errors: chunk.errors,
239
+ };
240
+ }
241
+
242
+ const nextPage = chunk.page + 1;
243
+ const res = await syncService.syncContentTypePage(chunk.uid, {
244
+ profile: syntheticProfile,
245
+ page: nextPage,
246
+ pageSize: chunk.pageSize || undefined,
247
+ });
248
+
249
+ chunk.page = res.page;
250
+ chunk.pageSize = res.pageSize;
251
+ chunk.pushed += res.pushed;
252
+ chunk.pulled += res.pulled;
253
+ chunk.errors += res.errors;
254
+ if (res.remotePageCount && !chunk.pagesTotal) {
255
+ chunk.pagesTotal = res.remotePageCount;
256
+ }
257
+ chunk.lastPageAt = now();
258
+ hasMore = !!res.hasMore;
259
+ }
260
+
261
+ return {
262
+ page: chunk.page,
263
+ pagesTotal: chunk.pagesTotal,
264
+ pushed: chunk.pushed,
265
+ pulled: chunk.pulled,
266
+ errors: chunk.errors,
267
+ };
268
+ }
269
+
270
+ async function runMediaChunk(job, chunk) {
271
+ const syncMedia = plugin().service('syncMedia');
272
+ if (!chunk.profileId) {
273
+ // No active media profile; skip with a clear status.
274
+ return { skipped: true, reason: chunk.warning || 'No media profile to run' };
275
+ }
276
+
277
+ // Media sync is not paginated internally — it runs as one long call per
278
+ // profile. Mark the chunk as "page 1 of 1" so the UI shows progress
279
+ // instead of a stale 0/0 while the job is working.
280
+ chunk.pageSize = chunk.pageSize || null;
281
+ chunk.page = 0;
282
+ chunk.pagesTotal = 1;
283
+ chunk.pushed = chunk.pushed || 0;
284
+ chunk.pulled = chunk.pulled || 0;
285
+ chunk.errors = chunk.errors || 0;
286
+ chunk.lastPageAt = now();
287
+
288
+ let summary;
289
+ if (syncMedia.runProfile) {
290
+ summary = await syncMedia.runProfile(chunk.profileId);
291
+ } else if (syncMedia.run) {
292
+ summary = await syncMedia.run({ profileId: chunk.profileId });
293
+ } else {
294
+ throw new Error('Media sync service does not expose runProfile/run');
295
+ }
296
+
297
+ // Normalize media summary onto chunk counters so summarize() (and the
298
+ // admin UI) surfaces runtime stats for media chunks the same way it
299
+ // does for content chunks.
300
+ const pushed = Number(summary?.pushed) || 0;
301
+ const pulled = Number(summary?.pulled) || 0;
302
+ const dbRowsUpdated = Number(summary?.dbRowsUpdated) || 0;
303
+ const morphLinksApplied = Number(summary?.morphLinksApplied) || 0;
304
+ const errorsArr = Array.isArray(summary?.errors) ? summary.errors : [];
305
+
306
+ // For media, "pushed/pulled" from the summary reflect file-byte ops.
307
+ // Add DB row and morph link updates into the directional counter so
308
+ // the UI's total isn't misleadingly zero for DB-rows-only profiles.
309
+ if (job.direction === 'push') {
310
+ chunk.pushed += pushed + dbRowsUpdated + morphLinksApplied;
311
+ } else {
312
+ chunk.pulled += pulled + dbRowsUpdated + morphLinksApplied;
313
+ }
314
+ chunk.errors += errorsArr.length;
315
+ chunk.page = 1;
316
+ chunk.pagesTotal = 1;
317
+ chunk.lastPageAt = now();
318
+
319
+ return {
320
+ ...summary,
321
+ pushed: chunk.pushed,
322
+ pulled: chunk.pulled,
323
+ errors: chunk.errors,
324
+ page: chunk.page,
325
+ pagesTotal: chunk.pagesTotal,
326
+ };
327
+ }
328
+
329
+ async function executeChunk(job, chunk) {
330
+ // When resuming a paused chunk, keep its accumulated progress.
331
+ if (chunk.status !== 'paused') {
332
+ chunk.status = 'running';
333
+ chunk.startedAt = chunk.startedAt || now();
334
+ } else {
335
+ chunk.status = 'running';
336
+ }
337
+ try {
338
+ let result;
339
+ switch (chunk.kind) {
340
+ case 'content':
341
+ case 'users':
342
+ case 'admins':
343
+ result = await runContentChunk(job, chunk);
344
+ break;
345
+ case 'media':
346
+ result = await runMediaChunk(job, chunk);
347
+ break;
348
+ default:
349
+ throw new Error(`Unknown chunk kind: ${chunk.kind}`);
350
+ }
351
+ if (result?.paused) {
352
+ chunk.status = 'paused';
353
+ chunk.result = result;
354
+ return { paused: true };
355
+ }
356
+ if (result?.cancelled) {
357
+ // Leave the chunk in 'paused' state so its progress is preserved
358
+ // and a future resume-from-history can pick it up.
359
+ chunk.status = 'paused';
360
+ chunk.result = result;
361
+ return { cancelled: true };
362
+ }
363
+ chunk.status = result?.skipped ? 'skipped' : 'success';
364
+ chunk.result = result || null;
365
+ } catch (err) {
366
+ chunk.status = 'error';
367
+ chunk.error = err.message;
368
+ job.errors.push({ index: chunk.index, label: chunk.label, error: err.message });
369
+ } finally {
370
+ if (chunk.status !== 'paused') {
371
+ chunk.completedAt = now();
372
+ }
373
+ }
374
+ return { paused: false };
375
+ }
376
+
377
+ async function assertRemoteConfigured() {
378
+ const config = await plugin().service('config').getConfig({ safe: false });
379
+ if (!config || !config.baseUrl) {
380
+ throw new Error('Remote server not configured');
381
+ }
382
+ return config;
383
+ }
384
+
385
+ return {
386
+ /**
387
+ * POST /bulk-transfer/start
388
+ * body: { direction: 'pull'|'push', scopes: {content,media,users,admins},
389
+ * syncDeletions, autoContinue, conflictStrategy? }
390
+ */
391
+ async start(options = {}) {
392
+ const direction = options.direction === 'push' ? 'push' : 'pull';
393
+ const scopes = {
394
+ content: !!options?.scopes?.content,
395
+ media: !!options?.scopes?.media,
396
+ users: !!options?.scopes?.users,
397
+ admins: !!options?.scopes?.admins,
398
+ };
399
+ if (!scopes.content && !scopes.media && !scopes.users && !scopes.admins) {
400
+ throw new Error('Select at least one scope (content, media, users, admins).');
401
+ }
402
+
403
+ // Single-side mode: only pull is allowed.
404
+ const pluginConfig = await plugin().service('config').getConfig({ safe: false });
405
+ if (pluginConfig?.syncMode === 'single_side' && direction !== 'pull') {
406
+ throw new Error('Single-side mode only supports pull bulk transfers.');
407
+ }
408
+
409
+ await assertRemoteConfigured();
410
+
411
+ const chunks = await buildPlan({ direction, scopes });
412
+ if (chunks.length === 0) {
413
+ throw new Error('No chunks produced for the selected scopes.');
414
+ }
415
+
416
+ // Apply user-selected subset if provided. `selectedIndexes` is a list
417
+ // of chunk indexes the user explicitly wants to run. Anything outside
418
+ // the set is marked skipped so the cursor can walk over them cheaply
419
+ // while the UI still shows the full plan.
420
+ const selectedIndexes = Array.isArray(options.selectedIndexes)
421
+ ? new Set(options.selectedIndexes.map((n) => Number(n)))
422
+ : null;
423
+ if (selectedIndexes && selectedIndexes.size > 0) {
424
+ let anySelected = false;
425
+ for (const c of chunks) {
426
+ const sel = selectedIndexes.has(c.index);
427
+ c.selected = sel;
428
+ if (!sel) c.status = 'skipped';
429
+ else anySelected = true;
430
+ }
431
+ if (!anySelected) {
432
+ throw new Error('No chunks selected to run.');
433
+ }
434
+ }
435
+
436
+ // Create a run report that the Stats tab will surface.
437
+ const syncStats = plugin().service('syncStats');
438
+ let reportHandle = null;
439
+ if (syncStats?.createRunReport) {
440
+ reportHandle = await syncStats.createRunReport({
441
+ runType: 'bulk_transfer',
442
+ trigger: `bulk_${direction}`,
443
+ contentTypes: chunks.map((c) => c.label),
444
+ });
445
+ }
446
+
447
+ const job = {
448
+ id: newJobId(),
449
+ direction,
450
+ scopes,
451
+ syncDeletions: !!options.syncDeletions,
452
+ autoContinue: !!options.autoContinue,
453
+ conflictStrategy: options.conflictStrategy || 'latest',
454
+ pageSize: Number(options.pageSize) || null,
455
+ status: 'running',
456
+ createdAt: now(),
457
+ startedAt: now(),
458
+ completedAt: null,
459
+ chunks,
460
+ cursor: 0,
461
+ errors: [],
462
+ reportId: reportHandle?.reportId || null,
463
+ };
464
+ jobs.set(job.id, job);
465
+ await persistJobToHistory(job);
466
+
467
+ // Auto-run in background; each chunk runs sequentially.
468
+ if (job.autoContinue) {
469
+ this.runToCompletion(job.id).catch((err) => {
470
+ strapi.log?.error?.(`[bulk-transfer] auto-run failed: ${err.message}`);
471
+ });
472
+ }
473
+
474
+ return this.summarize(job);
475
+ },
476
+
477
+ /**
478
+ * POST /bulk-transfer/:jobId/next
479
+ * Manually run the next pending chunk (when autoContinue is false).
480
+ */
481
+ async next(jobId) {
482
+ const job = jobs.get(jobId);
483
+ if (!job) throw new Error('Job not found');
484
+ if (job.status !== 'running') throw new Error(`Job is ${job.status}`);
485
+ // Skip over any deselected chunks without running them.
486
+ while (job.cursor < job.chunks.length && job.chunks[job.cursor].selected === false) {
487
+ job.chunks[job.cursor].status = 'skipped';
488
+ job.cursor += 1;
489
+ }
490
+ if (job.cursor >= job.chunks.length) {
491
+ await this.finish(job);
492
+ return this.summarize(job);
493
+ }
494
+ const chunk = job.chunks[job.cursor];
495
+ const r = await executeChunk(job, chunk);
496
+ if (!r.paused && !r.cancelled) {
497
+ job.cursor += 1;
498
+ }
499
+ if (job.cursor >= job.chunks.length) await this.finish(job);
500
+ await persistJobToHistory(job);
501
+ return this.summarize(job);
502
+ },
503
+
504
+ /**
505
+ * Run all remaining chunks without stopping.
506
+ */
507
+ async runToCompletion(jobId) {
508
+ const job = jobs.get(jobId);
509
+ if (!job) throw new Error('Job not found');
510
+ while (job.status === 'running' && job.cursor < job.chunks.length) {
511
+ const chunk = job.chunks[job.cursor];
512
+ if (chunk.selected === false) {
513
+ chunk.status = 'skipped';
514
+ job.cursor += 1;
515
+ continue;
516
+ }
517
+ // eslint-disable-next-line no-await-in-loop
518
+ const r = await executeChunk(job, chunk);
519
+ // eslint-disable-next-line no-await-in-loop
520
+ await persistJobToHistory(job);
521
+ if (r.paused || r.cancelled) break;
522
+ job.cursor += 1;
523
+ }
524
+ if (job.status === 'running' && job.cursor >= job.chunks.length) await this.finish(job);
525
+ await persistJobToHistory(job);
526
+ return this.summarize(job);
527
+ },
528
+
529
+ async finish(job) {
530
+ job.status = job.errors.length > 0 ? 'partial' : 'success';
531
+ job.completedAt = now();
532
+
533
+ const syncStats = plugin().service('syncStats');
534
+ if (job.reportId && syncStats?.completeRunReport) {
535
+ await syncStats.completeRunReport(job.reportId, {
536
+ status: job.errors.length > 0 ? 'partial' : 'success',
537
+ summary: {
538
+ direction: job.direction,
539
+ scopes: job.scopes,
540
+ syncDeletions: job.syncDeletions,
541
+ chunks: job.chunks.map((c) => ({
542
+ label: c.label,
543
+ status: c.status,
544
+ error: c.error || null,
545
+ })),
546
+ },
547
+ error: job.errors.length > 0 ? `${job.errors.length} chunk(s) failed` : null,
548
+ });
549
+ }
550
+ await persistJobToHistory(job);
551
+ },
552
+
553
+ async cancel(jobId) {
554
+ const job = jobs.get(jobId);
555
+ if (!job) throw new Error('Job not found');
556
+ if (job.status === 'running' || job.status === 'paused') {
557
+ job.status = 'cancelled';
558
+ job.completedAt = now();
559
+ const syncStats = plugin().service('syncStats');
560
+ if (job.reportId && syncStats?.completeRunReport) {
561
+ await syncStats.completeRunReport(job.reportId, {
562
+ status: 'error',
563
+ summary: null,
564
+ error: 'cancelled by user',
565
+ });
566
+ }
567
+ await persistJobToHistory(job);
568
+ }
569
+ return this.summarize(job);
570
+ },
571
+
572
+ /**
573
+ * Request a pause. The currently running chunk will exit at the next
574
+ * page boundary. `next` / `resume` can then re-enter the same chunk
575
+ * and pick up from the last completed page.
576
+ */
577
+ async pause(jobId) {
578
+ const job = jobs.get(jobId);
579
+ if (!job) throw new Error('Job not found');
580
+ if (job.status === 'running') {
581
+ job.status = 'paused';
582
+ job.pausedAt = now();
583
+ await persistJobToHistory(job);
584
+ }
585
+ return this.summarize(job);
586
+ },
587
+
588
+ /**
589
+ * Resume a paused job. If autoContinue was set, resumes the background
590
+ * run-to-completion loop; otherwise the UI will drive via `next`.
591
+ */
592
+ async resume(jobId) {
593
+ const job = jobs.get(jobId);
594
+ if (!job) throw new Error('Job not found');
595
+ if (job.status !== 'paused') throw new Error(`Job is ${job.status}`);
596
+ job.status = 'running';
597
+ job.resumedAt = now();
598
+ await persistJobToHistory(job);
599
+ if (job.autoContinue) {
600
+ this.runToCompletion(job.id).catch((err) => {
601
+ strapi.log?.error?.(`[bulk-transfer] resume auto-run failed: ${err.message}`);
602
+ });
603
+ }
604
+ return this.summarize(job);
605
+ },
606
+
607
+ getStatus(jobId) {
608
+ const job = jobs.get(jobId);
609
+ if (!job) return null;
610
+ return this.summarize(job);
611
+ },
612
+
613
+ listJobs() {
614
+ return [...jobs.values()].map((j) => this.summarize(j));
615
+ },
616
+
617
+ summarize(job) {
618
+ return {
619
+ id: job.id,
620
+ status: job.status,
621
+ direction: job.direction,
622
+ scopes: job.scopes,
623
+ syncDeletions: job.syncDeletions,
624
+ autoContinue: job.autoContinue,
625
+ cursor: job.cursor,
626
+ total: job.chunks.length,
627
+ createdAt: job.createdAt,
628
+ startedAt: job.startedAt,
629
+ completedAt: job.completedAt,
630
+ errors: job.errors,
631
+ chunks: job.chunks.map((c) => ({
632
+ index: c.index,
633
+ kind: c.kind,
634
+ uid: c.uid || null,
635
+ profileId: c.profileId || null,
636
+ label: c.label,
637
+ status: c.status,
638
+ selected: c.selected !== false,
639
+ error: c.error || null,
640
+ warning: c.warning || null,
641
+ startedAt: c.startedAt || null,
642
+ completedAt: c.completedAt || null,
643
+ page: c.page || 0,
644
+ pagesTotal: c.pagesTotal || null,
645
+ pageSize: c.pageSize || null,
646
+ pushed: c.pushed || 0,
647
+ pulled: c.pulled || 0,
648
+ errors: c.errors || 0,
649
+ })),
650
+ };
651
+ },
652
+
653
+ /**
654
+ * Return a preview plan without creating a job. Used by the UI to show
655
+ * chunk counts before starting.
656
+ */
657
+ async preview({ direction, scopes }) {
658
+ const chunks = await buildPlan({
659
+ direction: direction === 'push' ? 'push' : 'pull',
660
+ scopes: {
661
+ content: !!scopes?.content,
662
+ media: !!scopes?.media,
663
+ users: !!scopes?.users,
664
+ admins: !!scopes?.admins,
665
+ },
666
+ });
667
+ return { total: chunks.length, chunks };
668
+ },
669
+
670
+ /**
671
+ * Return the persisted bulk-transfer run history. Used by the admin UI
672
+ * to let the user review previous runs and restart a new one using the
673
+ * same (or adjusted) chunk selection.
674
+ */
675
+ async getHistory() {
676
+ const history = await readHistory();
677
+ return { total: history.length, items: history };
678
+ },
679
+
680
+ async clearHistory() {
681
+ await writeHistory([]);
682
+ return { total: 0, items: [] };
683
+ },
684
+
685
+ /**
686
+ * Create a new job from a persisted history entry. By default the
687
+ * previously selected chunks are reused, but the caller can override
688
+ * `selectedIndexes`, `direction`, `scopes`, etc.
689
+ *
690
+ * Restart always starts from scratch (cursor = 0, fresh counters); the
691
+ * prior job remains in history as a separate record.
692
+ */
693
+ async restart(historyId, overrides = {}) {
694
+ const history = await readHistory();
695
+ const source = history.find((h) => h.id === historyId);
696
+ if (!source) throw new Error('History entry not found');
697
+
698
+ const previouslySelected = (source.chunks || [])
699
+ .filter((c) => c.selected !== false)
700
+ .map((c) => c.index);
701
+
702
+ return this.start({
703
+ direction: overrides.direction || source.direction,
704
+ scopes: overrides.scopes || source.scopes,
705
+ syncDeletions: overrides.syncDeletions ?? source.syncDeletions,
706
+ autoContinue: overrides.autoContinue ?? source.autoContinue,
707
+ conflictStrategy: overrides.conflictStrategy || source.conflictStrategy,
708
+ selectedIndexes: Array.isArray(overrides.selectedIndexes)
709
+ ? overrides.selectedIndexes
710
+ : previouslySelected,
711
+ });
712
+ },
713
+
714
+ /**
715
+ * Resume a run that was persisted to history (including across server
716
+ * restarts). Rehydrates the chunk plan, carrying forward completed /
717
+ * skipped chunks and the paused chunk's page progress so execution
718
+ * picks up exactly where it left off.
719
+ *
720
+ * Only valid when the source run ended in a non-terminal state
721
+ * (paused / cancelled). For success / partial runs, use restart().
722
+ */
723
+ async resumeFromHistory(historyId, overrides = {}) {
724
+ const history = await readHistory();
725
+ const source = history.find((h) => h.id === historyId);
726
+ if (!source) throw new Error('History entry not found');
727
+ if (['success', 'partial'].includes(source.status)) {
728
+ throw new Error(`Run already ${source.status}; use restart instead.`);
729
+ }
730
+
731
+ await assertRemoteConfigured();
732
+
733
+ // Rebuild the plan from the same scopes so chunk targets still exist.
734
+ const direction = source.direction === 'push' ? 'push' : 'pull';
735
+ const freshChunks = await buildPlan({ direction, scopes: source.scopes });
736
+
737
+ // Merge prior per-chunk state onto the fresh plan by (kind,label).
738
+ // Completed / skipped chunks stay that way; paused chunks keep their
739
+ // page / counters so runContentChunk resumes on the next page.
740
+ const byKey = new Map(
741
+ (source.chunks || []).map((c) => [`${c.kind}::${c.label}`, c])
742
+ );
743
+
744
+ let cursor = 0;
745
+ let cursorSet = false;
746
+ freshChunks.forEach((fresh, i) => {
747
+ const prior = byKey.get(`${fresh.kind}::${fresh.label}`);
748
+ if (!prior) return;
749
+ fresh.selected = prior.selected !== false;
750
+ // Carry forward running counters
751
+ fresh.page = prior.page || 0;
752
+ fresh.pagesTotal = prior.pagesTotal || null;
753
+ fresh.pushed = prior.pushed || 0;
754
+ fresh.pulled = prior.pulled || 0;
755
+ fresh.errors = prior.errors || 0;
756
+ fresh.startedAt = prior.startedAt || null;
757
+ // Decide chunk status for resume:
758
+ // - success/skipped/error: keep terminal, don't re-run
759
+ // - paused: reset to pending so executeChunk re-enters; progress kept
760
+ // - running (server died mid-run): treat as paused to resume safely
761
+ // - pending: stay pending
762
+ if (['success', 'skipped', 'error'].includes(prior.status)) {
763
+ fresh.status = prior.status;
764
+ fresh.completedAt = prior.completedAt || null;
765
+ fresh.error = prior.error || null;
766
+ } else {
767
+ fresh.status = 'pending';
768
+ if (!cursorSet) {
769
+ cursor = i;
770
+ cursorSet = true;
771
+ }
772
+ }
773
+ });
774
+
775
+ // If every chunk is already terminal, there's nothing to resume.
776
+ if (!cursorSet) {
777
+ throw new Error('Nothing to resume — all chunks already finished.');
778
+ }
779
+
780
+ // Apply selection override if provided.
781
+ if (Array.isArray(overrides.selectedIndexes)) {
782
+ const set = new Set(overrides.selectedIndexes.map((n) => Number(n)));
783
+ for (const c of freshChunks) c.selected = set.has(c.index);
784
+ }
785
+
786
+ const syncStats = plugin().service('syncStats');
787
+ const reportHandle = syncStats?.createRunReport
788
+ ? await syncStats.createRunReport({
789
+ runType: 'bulk_transfer',
790
+ trigger: `bulk_transfer_resume_${direction}`,
791
+ contentTypes: freshChunks.filter((c) => c.uid).map((c) => c.uid),
792
+ })
793
+ : null;
794
+
795
+ const job = {
796
+ id: newJobId(),
797
+ status: 'running',
798
+ direction,
799
+ scopes: source.scopes,
800
+ syncDeletions: !!source.syncDeletions,
801
+ autoContinue: overrides.autoContinue ?? !!source.autoContinue,
802
+ conflictStrategy: overrides.conflictStrategy || source.conflictStrategy || 'latest',
803
+ cursor,
804
+ chunks: freshChunks,
805
+ errors: [],
806
+ createdAt: now(),
807
+ startedAt: now(),
808
+ completedAt: null,
809
+ reportId: reportHandle?.reportId || null,
810
+ resumedFrom: source.id,
811
+ };
812
+
813
+ jobs.set(job.id, job);
814
+ await persistJobToHistory(job);
815
+
816
+ if (job.autoContinue) {
817
+ this.runToCompletion(job.id).catch((err) => {
818
+ strapi.log?.error?.(`[bulk-transfer] resume-from-history auto-run failed: ${err.message}`);
819
+ });
820
+ }
821
+
822
+ // Include the rehydrated form state so the admin UI can restore
823
+ // direction / scopes / selection exactly as they were when the run
824
+ // was paused. This makes resume visually "pick up where it left off".
825
+ const summary = this.summarize(job);
826
+ summary.restoredState = {
827
+ direction,
828
+ scopes: { ...source.scopes },
829
+ syncDeletions: !!source.syncDeletions,
830
+ autoContinue: job.autoContinue,
831
+ conflictStrategy: job.conflictStrategy,
832
+ selectedIndexes: freshChunks.filter((c) => c.selected !== false).map((c) => c.index),
833
+ };
834
+ return summary;
835
+ },
836
+ };
837
+ };