@iwonz/ywtc 3.6.0 → 4.0.1

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 (58) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +17 -14
  3. package/dist/adapters/confluence.d.ts +0 -2
  4. package/dist/adapters/confluence.js +61 -27
  5. package/dist/adapters/confluence.js.map +1 -1
  6. package/dist/adapters/yandex.d.ts +7 -9
  7. package/dist/adapters/yandex.js +68 -27
  8. package/dist/adapters/yandex.js.map +1 -1
  9. package/dist/cli.js +2 -2
  10. package/dist/cli.js.map +1 -1
  11. package/dist/clients/confluence-cloud.d.ts +1 -0
  12. package/dist/clients/confluence-cloud.js +2 -2
  13. package/dist/clients/confluence-cloud.js.map +1 -1
  14. package/dist/clients/confluence.d.ts +1 -0
  15. package/dist/clients/confluence.js +4 -3
  16. package/dist/clients/confluence.js.map +1 -1
  17. package/dist/clients/yandex-wiki.js +2 -6
  18. package/dist/clients/yandex-wiki.js.map +1 -1
  19. package/dist/consistency.d.ts +1 -1
  20. package/dist/consistency.js +6 -1
  21. package/dist/consistency.js.map +1 -1
  22. package/dist/content-model.d.ts +1 -0
  23. package/dist/content-model.js +118 -37
  24. package/dist/content-model.js.map +1 -1
  25. package/dist/conversion.d.ts +2 -0
  26. package/dist/conversion.js +42 -17
  27. package/dist/conversion.js.map +1 -1
  28. package/dist/domain.d.ts +27 -4
  29. package/dist/domain.js +20 -0
  30. package/dist/domain.js.map +1 -1
  31. package/dist/exclusive-lease.d.ts +14 -0
  32. package/dist/exclusive-lease.js +87 -0
  33. package/dist/exclusive-lease.js.map +1 -0
  34. package/dist/filesystem-export.d.ts +2 -0
  35. package/dist/filesystem-export.js +188 -167
  36. package/dist/filesystem-export.js.map +1 -1
  37. package/dist/graph.js +4 -4
  38. package/dist/graph.js.map +1 -1
  39. package/dist/http.js +1 -1
  40. package/dist/http.js.map +1 -1
  41. package/dist/interactive-config.js +55 -3
  42. package/dist/interactive-config.js.map +1 -1
  43. package/dist/issues.d.ts +1 -1
  44. package/dist/migrator.js +604 -366
  45. package/dist/migrator.js.map +1 -1
  46. package/dist/report-catalog.d.ts +4 -0
  47. package/dist/report-catalog.js +4 -0
  48. package/dist/report-catalog.js.map +1 -1
  49. package/dist/report-contract.d.ts +6 -0
  50. package/dist/report-contract.js.map +1 -1
  51. package/dist/report-ui/index.html +6 -6
  52. package/dist/report.js +34 -23
  53. package/dist/report.js.map +1 -1
  54. package/dist/state.d.ts +18 -0
  55. package/dist/state.js +60 -32
  56. package/dist/state.js.map +1 -1
  57. package/dist/types.d.ts +1 -0
  58. package/package.json +1 -1
package/dist/migrator.js CHANGED
@@ -5,10 +5,10 @@ import { pathToFileURL } from 'node:url';
5
5
  import PQueue from 'p-queue';
6
6
  import { createProviderAdapter } from './adapters/factory.js';
7
7
  import { secretValues } from './config.js';
8
- import { appendGridSnapshots, assertDestinationContent, emptyRendererFallbackReason, isLegacyTransferPlaceholder, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, setSourceResourceBinding, sourceResourceBindings, verifyDestinationContent, } from './content-model.js';
8
+ import { appendGridSnapshots, assertDestinationContent, emptyRendererFallbackReason, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, setSourceResourceBinding, sourceResourceBindings, verifyDestinationContent, } from './content-model.js';
9
9
  import { commentAttachmentUses, createCommentArchiveZip, renderCommentArchive, renderCommentArchiveIndex, resolveCommentAttachmentReferences, verifyCommentArchive, } from './comments.js';
10
10
  import { ConfirmedDestinationMissingError, verifyConfirmedDestinationPage } from './consistency.js';
11
- import { DestinationTitleConflictError } from './domain.js';
11
+ import { DestinationOwnershipConflictError, DestinationRevisionConflictError, DestinationTitleConflictError } from './domain.js';
12
12
  import { assertTransferCapabilities } from './domain.js';
13
13
  import { FilesystemExporter } from './filesystem-export.js';
14
14
  import { discoverPageGraph } from './graph.js';
@@ -105,6 +105,89 @@ function sourceDerivedTextMatches(expectedBody, actualBody, provider, page) {
105
105
  const text = (value) => renderedPreviewHtml(value, provider).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
106
106
  return text(expectedBody) === text(actualBody);
107
107
  }
108
+ const REVISION_RETRY_DELAYS_MS = [500, 1_000, 2_000];
109
+ async function revisionDelay(attempt, options) {
110
+ const base = REVISION_RETRY_DELAYS_MS[attempt];
111
+ const random = options.random ?? Math.random;
112
+ const delay = Math.max(1, Math.round(base * (0.8 + random() * 0.4)));
113
+ await (options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))))(delay);
114
+ return delay;
115
+ }
116
+ async function updateMappedPage(input) {
117
+ let current = input.current;
118
+ let title = input.title;
119
+ let retainedMappedTitle = false;
120
+ for (let attempt = 0;; attempt += 1) {
121
+ const verification = verifyDestinationContent(input.body, current.content, input.destination.kind, input.sourcePage);
122
+ if (verification.status === 'complete' && current.title === title) {
123
+ return { page: current, updated: false, normalized: current.content !== input.body, verification };
124
+ }
125
+ try {
126
+ const result = await input.destination.updatePage({ page: current, title, body: input.body });
127
+ const confirmed = await verifyConfirmedDestinationPage(input.destination, result, input.consistencyOptions);
128
+ return {
129
+ page: confirmed,
130
+ updated: true,
131
+ normalized: confirmed.content !== input.body,
132
+ verification: verifyDestinationContent(input.body, confirmed.content, input.destination.kind, input.sourcePage),
133
+ };
134
+ }
135
+ catch (error) {
136
+ if (error instanceof DestinationTitleConflictError && !retainedMappedTitle && current.title !== title) {
137
+ title = current.title;
138
+ retainedMappedTitle = true;
139
+ input.report.issue({
140
+ code: 'mapped-title-retained',
141
+ severity: 'normalization',
142
+ phase: 'page',
143
+ pageId: input.sourcePage.canonicalId,
144
+ retryable: false,
145
+ message: `${input.sourcePage.title}: retained mapped destination title "${current.title}" because the source title is occupied`,
146
+ });
147
+ continue;
148
+ }
149
+ if (!(error instanceof DestinationRevisionConflictError) || attempt >= REVISION_RETRY_DELAYS_MS.length)
150
+ throw error;
151
+ const delay = await revisionDelay(attempt, input.consistencyOptions);
152
+ input.report.issue({
153
+ code: 'destination-revision-retry',
154
+ severity: 'normalization',
155
+ phase: 'page',
156
+ pageId: input.sourcePage.canonicalId,
157
+ retryable: true,
158
+ message: `${input.sourcePage.title}: destination revision changed concurrently; retry ${attempt + 1} after ${delay} ms`,
159
+ });
160
+ current = await verifyConfirmedDestinationPage(input.destination, current, input.consistencyOptions);
161
+ }
162
+ }
163
+ }
164
+ async function reparentMappedPage(input) {
165
+ if (!input.destination.reparentPage)
166
+ throw new Error(`${input.destination.profile.name} does not implement page moves`);
167
+ let current = input.current;
168
+ for (let attempt = 0;; attempt += 1) {
169
+ if (current.parentId === input.parent.id)
170
+ return current;
171
+ try {
172
+ const moved = await input.destination.reparentPage({ page: current, parent: input.parent });
173
+ return verifyConfirmedDestinationPage(input.destination, moved, input.consistencyOptions);
174
+ }
175
+ catch (error) {
176
+ if (!(error instanceof DestinationRevisionConflictError) || attempt >= REVISION_RETRY_DELAYS_MS.length)
177
+ throw error;
178
+ const delay = await revisionDelay(attempt, input.consistencyOptions);
179
+ input.report.issue({
180
+ code: 'destination-revision-retry',
181
+ severity: 'normalization',
182
+ phase: 'page',
183
+ pageId: input.sourcePage.canonicalId,
184
+ retryable: true,
185
+ message: `${input.sourcePage.title}: destination revision changed during hierarchy repair; retry ${attempt + 1} after ${delay} ms`,
186
+ });
187
+ current = await verifyConfirmedDestinationPage(input.destination, current, input.consistencyOptions);
188
+ }
189
+ }
190
+ }
108
191
  function ambiguousCreateOutcome(error) {
109
192
  return error instanceof HttpRequestError
110
193
  && (error.status === undefined || error.status >= 500);
@@ -173,21 +256,23 @@ async function createPageWithAllocatedTitle(input) {
173
256
  throw new Error(`Confluence rejected all ${candidates.length} deterministic destination titles for ${input.sourcePage.title} because they are occupied`);
174
257
  }
175
258
  function destinationFromState(state, adapter) {
176
- if (!state.destinationId || !state.destinationCanonicalId || !state.destinationLocator) {
259
+ if (!state.destinationId) {
177
260
  throw new Error(`Incomplete mapped destination for ${state.sourceTitle}`);
178
261
  }
179
262
  return {
180
263
  provider: adapter.kind,
181
264
  profile: adapter.profile.name,
182
265
  id: state.destinationId,
183
- canonicalId: state.destinationCanonicalId,
266
+ canonicalId: state.destinationCanonicalId ?? `${adapter.profile.name}:${adapter.kind}:${state.destinationId}`,
184
267
  title: state.destinationTitle,
185
- url: state.destinationLocator,
268
+ url: state.destinationLocator ?? state.destinationId,
186
269
  locator: state.destinationId,
187
270
  ...(state.destinationFingerprint ? { fingerprint: state.destinationFingerprint } : {}),
271
+ ...(state.destinationVersion ? { version: state.destinationVersion } : {}),
188
272
  };
189
273
  }
190
274
  function reportPage(input) {
275
+ const effectiveWarnings = input.warnings ?? input.document.warnings;
191
276
  const inventory = input.commentInventory;
192
277
  const commentResourceIds = new Set();
193
278
  for (const thread of inventory?.threads ?? []) {
@@ -290,11 +375,14 @@ function reportPage(input) {
290
375
  tables: input.document.metrics.tables,
291
376
  grids: input.resources.filter((resource) => resource.kind === 'grid').length,
292
377
  codeBlocks: input.document.metrics.codeBlocks,
293
- degraded: input.document.degraded || (input.resourceFallbacks?.length ?? 0) > 0,
378
+ degraded: effectiveWarnings.length > 0 || (input.resourceFallbacks?.length ?? 0) > 0,
294
379
  resourceFallbacks: input.resourceFallbacks ?? [],
295
- warnings: [...new Set([...input.document.warnings, ...(input.warnings ?? [])])],
380
+ warnings: [...new Set(effectiveWarnings)],
296
381
  normalizations: [...new Set([...(input.document.normalizations ?? []), ...(input.normalizations ?? [])])],
382
+ adaptations: [...new Set([...(input.document.adaptations ?? []), ...(input.adaptations ?? [])])],
383
+ preservedExternalReferences: input.preservedExternalReferences ?? input.document.metrics.preservedExternalReferences,
297
384
  ...(input.error ? { error: input.error } : {}),
385
+ ...(input.conflictEvidence ? { conflictEvidence: input.conflictEvidence } : {}),
298
386
  ...(input.status === 'blocked' && input.blockedBy ? input.blockedBy : {}),
299
387
  comments: {
300
388
  status: commentStatus,
@@ -354,13 +442,14 @@ function runStatus(errors, warnings, degradations) {
354
442
  return 'partial';
355
443
  return warnings + degradations > 0 ? 'completed-with-warnings' : 'completed';
356
444
  }
357
- function attentionCount(summary) {
358
- return summary.warnings + summary.degradations;
359
- }
360
445
  function runOutcomeCounts(summary, pages) {
361
446
  return {
362
447
  pages,
363
- warnings: attentionCount(summary),
448
+ warnings: summary.warnings,
449
+ degradations: summary.degradations,
450
+ adaptations: summary.adaptations,
451
+ normalizations: summary.normalizations,
452
+ preservedExternalReferences: summary.preservedExternalReferences,
364
453
  errors: summary.errors,
365
454
  rootErrors: summary.rootErrors,
366
455
  dependentPages: summary.dependentPages,
@@ -432,277 +521,282 @@ export class Migrator {
432
521
  });
433
522
  const store = new StateStore(stateLocation.filename);
434
523
  await store.load(identity, { fresh: options.fresh ?? false, dryRun: options.dryRun });
435
- if (!options.dryRun)
436
- await store.save();
437
- const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
438
- const report = new TransferReport({
439
- secrets: secretValues(this.config),
440
- dryRun: options.dryRun,
441
- commentsEnabled,
442
- scope: options.scope,
443
- sourceStatus,
444
- direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
445
- source: { provider: source.kind, profile: source.profile.name, rootId: root.id, title: root.title, url: root.url },
446
- destination: options.destination.type === 'directory'
447
- ? { provider: 'filesystem', id: options.destination.path, format: options.destination.format }
448
- : { provider: destination.kind, profile: destination.profile.name, id: container.id, url: container.url },
449
- state: {
450
- id: stateLocation.id,
451
- path: stateLocation.filename,
452
- mode: stateLocation.mode,
453
- source: store.loadInfo.source,
454
- ...(store.loadInfo.backupFilename ? { backupPath: store.loadInfo.backupFilename } : {}),
455
- },
456
- });
457
- report.registerPages([{ ...root, depth: 0 }]);
458
- let graph;
459
- let sourceFingerprints;
460
524
  try {
461
- status(`Discovering ${options.scope} scope from ${root.title}`);
462
- graph = await discoverPageGraph({
463
- adapter: source,
464
- root,
525
+ if (!options.dryRun)
526
+ await store.save();
527
+ const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
528
+ const report = new TransferReport({
529
+ secrets: secretValues(this.config),
530
+ dryRun: options.dryRun,
531
+ commentsEnabled,
465
532
  scope: options.scope,
466
533
  sourceStatus,
467
- ...(options.signal ? { signal: options.signal } : {}),
468
- onProgress: (progress) => status(`Discovery: ${progress.pages} pages, ${progress.edges} links${progress.active ? ` · ${progress.active}` : ''}`),
469
- });
470
- report.registerPages(graph.pages);
471
- report.setEdges(graph.edges);
472
- report.setSelection(graph.selection);
473
- await store.setRunSelection(run.id, graph.selection);
474
- for (const warning of graph.warnings)
475
- report.warning(warning);
476
- if (container
477
- && options.destination.type === 'remote'
478
- && sameRemoteInstance(options.source.profile, options.destination.profile)
479
- && graph.pages.some((page) => page.id === container.id)) {
480
- throw new Error('The destination page belongs to the selected source graph. Choose a destination outside the transferred graph.');
481
- }
482
- status(`Source tree: root ${root.title} · ${graph.selection.returnedDescendants} descendants returned · ${graph.selection.selectedDescendants} selected · ${graph.selection.excluded.length} excluded · depth ${graph.selection.maximumDepth}`);
483
- status(`Caching source content for ${graph.pages.length} pages`);
484
- sourceFingerprints = await cacheDiscoveredPages(options.cacheDirectory, graph);
485
- await store.setGraph(graph.rootId, graph.edges);
486
- }
487
- catch (error) {
488
- const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
489
- const message = error instanceof Error ? error.message : String(error);
490
- report.updatePage(root.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
491
- report.issue({
492
- code: 'source-discovery-failed',
493
- severity: 'error',
494
- phase: 'discovery',
495
- pageId: root.canonicalId,
496
- retryable: true,
497
- message,
534
+ direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
535
+ source: { provider: source.kind, profile: source.profile.name, rootId: root.id, title: root.title, url: root.url },
536
+ destination: options.destination.type === 'directory'
537
+ ? { provider: 'filesystem', id: options.destination.path, format: options.destination.format }
538
+ : { provider: destination.kind, profile: destination.profile.name, id: container.id, url: container.url },
539
+ state: {
540
+ id: stateLocation.id,
541
+ path: stateLocation.filename,
542
+ mode: stateLocation.mode,
543
+ source: store.loadInfo.source,
544
+ ...(store.loadInfo.backupFilename ? { backupPath: store.loadInfo.backupFilename } : {}),
545
+ },
498
546
  });
499
- report.finish(finalStatus);
500
- const reportPaths = await report.write(options.reportsDirectory);
501
- if (!options.dryRun)
502
- await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, 0));
503
- throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
504
- }
505
- const resourcesByPage = new Map();
506
- const commentsByPage = new Map();
507
- const inventoryQueue = new PQueue({ concurrency: options.concurrency });
508
- const inventory = { resources: 0, bytes: 0, unknownSizes: 0, grids: 0, links: 0, images: 0, tables: 0, codeBlocks: 0, commentThreads: 0, comments: 0, commentParticipants: 0, commentAttachments: 0, commentBytes: 0 };
509
- status(`Inspecting content and resources for ${graph.pages.length} pages`);
510
- const inventoryErrors = [];
511
- await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
547
+ report.registerPages([{ ...root, depth: 0 }]);
548
+ let graph;
549
+ let sourceFingerprints;
512
550
  try {
513
- if (options.signal?.aborted)
514
- throw options.signal.reason ?? new Error('Transfer cancelled');
515
- const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
516
- const resources = await source.listResources(page);
517
- resourcesByPage.set(page.canonicalId, resources);
518
- let commentInventory;
519
- if (commentsEnabled) {
520
- try {
521
- if (!source.readComments)
522
- throw new Error(`${source.profile.name} does not implement comment reads`);
523
- commentInventory = await source.readComments(cachedPage);
524
- resolveCommentAttachmentReferences(commentInventory, resources);
525
- commentsByPage.set(page.canonicalId, commentInventory);
526
- inventory.commentThreads += commentInventory.threads.length;
527
- inventory.comments += commentInventory.comments;
528
- inventory.commentParticipants += commentInventory.participants;
529
- inventory.commentAttachments += commentInventory.attachments;
530
- inventory.commentBytes += commentInventory.knownBytes;
531
- for (const warning of commentInventory.warnings)
532
- report.issue({
533
- code: `comment-inventory-warning:${warning}`,
534
- severity: 'warning',
535
- phase: 'inventory',
536
- pageId: page.canonicalId,
537
- retryable: false,
538
- message: `${page.title}: ${warning}`,
539
- });
540
- }
541
- catch (error) {
542
- const message = `${page.title}: comments could not be read: ${error instanceof Error ? error.message : String(error)}`;
543
- commentInventory = { status: 'failed', threads: [], comments: 0, participants: 0, attachments: 0, knownBytes: 0, unknownSizes: 0, warnings: [], error: message };
544
- commentsByPage.set(page.canonicalId, commentInventory);
545
- report.issue({ code: 'comment-inventory-failed', severity: 'error', phase: 'inventory', pageId: page.canonicalId, retryable: true, message });
546
- }
551
+ status(`Discovering ${options.scope} scope from ${root.title}`);
552
+ graph = await discoverPageGraph({
553
+ adapter: source,
554
+ root,
555
+ scope: options.scope,
556
+ sourceStatus,
557
+ ...(options.signal ? { signal: options.signal } : {}),
558
+ onProgress: (progress) => status(`Discovery: ${progress.pages} pages, ${progress.edges} links${progress.active ? ` · ${progress.active}` : ''}`),
559
+ });
560
+ report.registerPages(graph.pages);
561
+ report.setEdges(graph.edges);
562
+ report.setSelection(graph.selection);
563
+ await store.setRunSelection(run.id, graph.selection);
564
+ for (const warning of graph.warnings)
565
+ report.warning(warning);
566
+ if (container
567
+ && options.destination.type === 'remote'
568
+ && sameRemoteInstance(options.source.profile, options.destination.profile)
569
+ && graph.pages.some((page) => page.id === container.id)) {
570
+ throw new Error('The destination page belongs to the selected source graph. Choose a destination outside the transferred graph.');
547
571
  }
548
- const document = appendGridSnapshots(normalizePageContent(cachedPage), resources);
549
- await cacheDocument(options.cacheDirectory, page.canonicalId, document);
550
- inventory.resources += resources.filter((resource) => resource.kind !== 'grid').length;
551
- inventory.bytes += resources.reduce((total, resource) => total + (resource.size ?? 0), 0);
552
- inventory.unknownSizes += resources.filter((resource) => resource.kind !== 'grid' && resource.size === undefined).length;
553
- inventory.grids += resources.filter((resource) => resource.kind === 'grid').length;
554
- inventory.links += document.metrics.links;
555
- inventory.images += document.metrics.images;
556
- inventory.tables += document.metrics.tables;
557
- inventory.codeBlocks += document.metrics.codeBlocks;
558
- report.addPage(reportPage({
559
- page,
560
- status: 'planned',
561
- resources,
562
- document,
563
- commentsEnabled,
564
- ...(commentInventory ? { commentInventory } : {}),
565
- }), undefined, false);
566
- status(`Inspected ${page.title} · ${resources.length} resources`);
572
+ status(`Source tree: root ${root.title} · ${graph.selection.returnedDescendants} descendants returned · ${graph.selection.selectedDescendants} selected · ${graph.selection.excluded.length} excluded · depth ${graph.selection.maximumDepth}`);
573
+ status(`Caching source content for ${graph.pages.length} pages`);
574
+ sourceFingerprints = await cacheDiscoveredPages(options.cacheDirectory, graph);
575
+ await store.setGraph(graph.rootId, graph.edges);
567
576
  }
568
577
  catch (error) {
569
- const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
570
- inventoryErrors.push(message);
571
- report.updatePage(page.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
578
+ const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
579
+ const message = error instanceof Error ? error.message : String(error);
580
+ report.updatePage(root.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
572
581
  report.issue({
573
- code: 'source-inventory-failed',
582
+ code: 'source-discovery-failed',
574
583
  severity: 'error',
575
- phase: 'inventory',
576
- pageId: page.canonicalId,
584
+ phase: 'discovery',
585
+ pageId: root.canonicalId,
577
586
  retryable: true,
578
587
  message,
579
588
  });
589
+ report.finish(finalStatus);
590
+ const reportPaths = await report.write(options.reportsDirectory);
591
+ if (!options.dryRun)
592
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, 0));
593
+ throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
580
594
  }
581
- })));
582
- await store.setRunComments(run.id, { threads: inventory.commentThreads, comments: inventory.comments, attachments: inventory.commentAttachments });
583
- if (inventoryErrors.length > 0) {
584
- const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
585
- report.finish(finalStatus);
586
- const reportPaths = await report.write(options.reportsDirectory);
587
- if (!options.dryRun)
588
- await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
589
- throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
590
- }
591
- if (!options.dryRun) {
592
- for (const page of graph.pages) {
593
- if (store.value.pages[page.canonicalId])
594
- continue;
595
- const commentInventory = commentsByPage.get(page.canonicalId);
596
- const commentState = !commentsEnabled
597
- ? { status: 'skipped', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
598
- : commentInventory?.status === 'empty'
599
- ? { status: 'complete', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
600
- : commentInventory?.status === 'failed'
601
- ? {
602
- status: 'failed', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0,
603
- ...(commentInventory.error ? { lastError: commentInventory.error } : {}), updatedAt: new Date().toISOString(),
604
- }
605
- : commentInventory
595
+ const resourcesByPage = new Map();
596
+ const commentsByPage = new Map();
597
+ const inventoryQueue = new PQueue({ concurrency: options.concurrency });
598
+ const inventory = { resources: 0, bytes: 0, unknownSizes: 0, grids: 0, links: 0, images: 0, tables: 0, codeBlocks: 0, commentThreads: 0, comments: 0, commentParticipants: 0, commentAttachments: 0, commentBytes: 0 };
599
+ status(`Inspecting content and resources for ${graph.pages.length} pages`);
600
+ const inventoryErrors = [];
601
+ await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
602
+ try {
603
+ if (options.signal?.aborted)
604
+ throw options.signal.reason ?? new Error('Transfer cancelled');
605
+ const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
606
+ const resources = await source.listResources(page);
607
+ resourcesByPage.set(page.canonicalId, resources);
608
+ let commentInventory;
609
+ if (commentsEnabled) {
610
+ try {
611
+ if (!source.readComments)
612
+ throw new Error(`${source.profile.name} does not implement comment reads`);
613
+ commentInventory = await source.readComments(cachedPage);
614
+ resolveCommentAttachmentReferences(commentInventory, resources);
615
+ commentsByPage.set(page.canonicalId, commentInventory);
616
+ inventory.commentThreads += commentInventory.threads.length;
617
+ inventory.comments += commentInventory.comments;
618
+ inventory.commentParticipants += commentInventory.participants;
619
+ inventory.commentAttachments += commentInventory.attachments;
620
+ inventory.commentBytes += commentInventory.knownBytes;
621
+ for (const warning of commentInventory.warnings)
622
+ report.issue({
623
+ code: `comment-inventory-warning:${warning}`,
624
+ severity: 'warning',
625
+ phase: 'inventory',
626
+ pageId: page.canonicalId,
627
+ retryable: false,
628
+ message: `${page.title}: ${warning}`,
629
+ });
630
+ }
631
+ catch (error) {
632
+ const message = `${page.title}: comments could not be read: ${error instanceof Error ? error.message : String(error)}`;
633
+ commentInventory = { status: 'failed', threads: [], comments: 0, participants: 0, attachments: 0, knownBytes: 0, unknownSizes: 0, warnings: [], error: message };
634
+ commentsByPage.set(page.canonicalId, commentInventory);
635
+ report.issue({ code: 'comment-inventory-failed', severity: 'error', phase: 'inventory', pageId: page.canonicalId, retryable: true, message });
636
+ }
637
+ }
638
+ const document = appendGridSnapshots(normalizePageContent(cachedPage), resources);
639
+ await cacheDocument(options.cacheDirectory, page.canonicalId, document);
640
+ inventory.resources += resources.filter((resource) => resource.kind !== 'grid').length;
641
+ inventory.bytes += resources.reduce((total, resource) => total + (resource.size ?? 0), 0);
642
+ inventory.unknownSizes += resources.filter((resource) => resource.kind !== 'grid' && resource.size === undefined).length;
643
+ inventory.grids += resources.filter((resource) => resource.kind === 'grid').length;
644
+ inventory.links += document.metrics.links;
645
+ inventory.images += document.metrics.images;
646
+ inventory.tables += document.metrics.tables;
647
+ inventory.codeBlocks += document.metrics.codeBlocks;
648
+ report.addPage(reportPage({
649
+ page,
650
+ status: 'planned',
651
+ resources,
652
+ document,
653
+ commentsEnabled,
654
+ ...(commentInventory ? { commentInventory } : {}),
655
+ }), undefined, false);
656
+ status(`Inspected ${page.title} · ${resources.length} resources`);
657
+ }
658
+ catch (error) {
659
+ const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
660
+ inventoryErrors.push(message);
661
+ report.updatePage(page.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
662
+ report.issue({
663
+ code: 'source-inventory-failed',
664
+ severity: 'error',
665
+ phase: 'inventory',
666
+ pageId: page.canonicalId,
667
+ retryable: true,
668
+ message,
669
+ });
670
+ }
671
+ })));
672
+ await store.setRunComments(run.id, { threads: inventory.commentThreads, comments: inventory.comments, attachments: inventory.commentAttachments });
673
+ if (inventoryErrors.length > 0) {
674
+ const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
675
+ report.finish(finalStatus);
676
+ const reportPaths = await report.write(options.reportsDirectory);
677
+ if (!options.dryRun)
678
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
679
+ throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
680
+ }
681
+ if (!options.dryRun) {
682
+ for (const page of graph.pages) {
683
+ if (store.value.pages[page.canonicalId])
684
+ continue;
685
+ const commentInventory = commentsByPage.get(page.canonicalId);
686
+ const commentState = !commentsEnabled
687
+ ? { status: 'skipped', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
688
+ : commentInventory?.status === 'empty'
689
+ ? { status: 'complete', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
690
+ : commentInventory?.status === 'failed'
606
691
  ? {
607
- status: 'planned', ...(commentInventory.fingerprint ? { fingerprint: commentInventory.fingerprint } : {}),
608
- threads: commentInventory.threads.length, comments: commentInventory.comments,
609
- participants: commentInventory.participants, attachments: commentInventory.attachments,
610
- bytes: commentInventory.knownBytes, updatedAt: new Date().toISOString(),
692
+ status: 'failed', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0,
693
+ ...(commentInventory.error ? { lastError: commentInventory.error } : {}), updatedAt: new Date().toISOString(),
611
694
  }
612
- : undefined;
613
- const resourceStates = Object.fromEntries((resourcesByPage.get(page.canonicalId) ?? [])
614
- .filter((resource) => resource.kind !== 'grid')
615
- .map((resource) => [resource.id, {
616
- sourceId: resource.id,
617
- sourceName: resource.name,
618
- destinationName: preferredDestinationName(resource),
619
- ...(resource.size !== undefined ? { size: resource.size } : {}),
620
- ...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
621
- status: 'planned',
695
+ : commentInventory
696
+ ? {
697
+ status: 'planned', ...(commentInventory.fingerprint ? { fingerprint: commentInventory.fingerprint } : {}),
698
+ threads: commentInventory.threads.length, comments: commentInventory.comments,
699
+ participants: commentInventory.participants, attachments: commentInventory.attachments,
700
+ bytes: commentInventory.knownBytes, updatedAt: new Date().toISOString(),
701
+ }
702
+ : undefined;
703
+ const resourceStates = Object.fromEntries((resourcesByPage.get(page.canonicalId) ?? [])
704
+ .filter((resource) => resource.kind !== 'grid')
705
+ .map((resource) => [resource.id, {
706
+ sourceId: resource.id,
707
+ sourceName: resource.name,
708
+ destinationName: preferredDestinationName(resource),
709
+ ...(resource.size !== undefined ? { size: resource.size } : {}),
710
+ ...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
711
+ status: 'planned',
712
+ updatedAt: new Date().toISOString(),
713
+ }]));
714
+ await store.setPage({
715
+ sourceId: page.id,
716
+ sourceCanonicalId: page.canonicalId,
717
+ sourceLocator: page.locator,
718
+ sourceTitle: page.title,
719
+ ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
720
+ destinationTitle: page.title,
721
+ sourceFingerprint: sourceFingerprints.get(page.canonicalId),
722
+ status: 'discovered',
723
+ resources: resourceStates,
724
+ ...(commentState ? { comments: commentState } : {}),
725
+ degradations: [],
622
726
  updatedAt: new Date().toISOString(),
623
- }]));
624
- await store.setPage({
625
- sourceId: page.id,
626
- sourceCanonicalId: page.canonicalId,
627
- sourceLocator: page.locator,
628
- sourceTitle: page.title,
629
- ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
630
- destinationTitle: page.title,
631
- sourceFingerprint: sourceFingerprints.get(page.canonicalId),
632
- status: 'discovered',
633
- resources: resourceStates,
634
- ...(commentState ? { comments: commentState } : {}),
635
- degradations: [],
636
- updatedAt: new Date().toISOString(),
637
- });
727
+ });
728
+ }
638
729
  }
639
- }
640
- const planSummary = `Plan ready: root + ${graph.selection.selectedDescendants}/${graph.selection.returnedDescendants} descendants (${graph.selection.excluded.length} excluded, depth ${graph.selection.maximumDepth}) · ${graph.pages.length} pages · ${inventory.resources} files (${inventory.bytes} known bytes${inventory.unknownSizes > 0 ? ` · ${inventory.unknownSizes} sizes unknown` : ''}) · ${inventory.links} links · ${inventory.images} images · ${inventory.tables} tables (${inventory.grids} grids) · ${inventory.codeBlocks} code blocks · ${commentsEnabled ? `${inventory.commentThreads} comment threads / ${inventory.comments} entries / ${inventory.commentAttachments} files (${inventory.commentBytes} known bytes)` : 'comments skipped'}`;
641
- status(planSummary);
642
- this.logger.info(planSummary);
643
- let output;
644
- let archive;
645
- try {
646
- if (options.destination.type === 'directory') {
647
- const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage, commentsByPage);
648
- output = result.directory;
649
- archive = result.archive;
650
- const exportEntry = path.join(result.directory, options.destination.format === 'html' ? 'index.html' : 'index.md');
651
- report.setDestinationUrl(pathToFileURL(exportEntry).toString());
652
- report.output('directory', result.directory);
653
- if (result.archive)
654
- report.output('archive', result.archive);
655
- if (result.backup)
656
- report.output('backup', result.backup);
730
+ const planSummary = `Plan ready: root + ${graph.selection.selectedDescendants}/${graph.selection.returnedDescendants} descendants (${graph.selection.excluded.length} excluded, depth ${graph.selection.maximumDepth}) · ${graph.pages.length} pages · ${inventory.resources} files (${inventory.bytes} known bytes${inventory.unknownSizes > 0 ? ` · ${inventory.unknownSizes} sizes unknown` : ''}) · ${inventory.links} links · ${inventory.images} images · ${inventory.tables} tables (${inventory.grids} grids) · ${inventory.codeBlocks} code blocks · ${commentsEnabled ? `${inventory.commentThreads} comment threads / ${inventory.comments} entries / ${inventory.commentAttachments} files (${inventory.commentBytes} known bytes)` : 'comments skipped'}`;
731
+ status(planSummary);
732
+ this.logger.info(planSummary);
733
+ let output;
734
+ let archive;
735
+ try {
736
+ if (options.destination.type === 'directory') {
737
+ const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage, commentsByPage);
738
+ output = result.directory;
739
+ archive = result.archive;
740
+ const exportEntry = path.join(result.directory, options.destination.format === 'html' ? 'index.html' : 'index.md');
741
+ report.setDestinationUrl(pathToFileURL(exportEntry).toString());
742
+ report.output('directory', result.directory);
743
+ if (result.archive)
744
+ report.output('archive', result.archive);
745
+ if (result.backup)
746
+ report.output('backup', result.backup);
747
+ }
748
+ else {
749
+ await this.transferRemote({
750
+ source,
751
+ destination: destination,
752
+ container: container,
753
+ graph,
754
+ options,
755
+ store,
756
+ report,
757
+ sourceFingerprints,
758
+ resourcesByPage,
759
+ commentsByPage,
760
+ });
761
+ output = container.url;
762
+ report.output('destination', container.url);
763
+ }
764
+ report.finish('completed');
765
+ const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings, report.data.summary.degradations);
766
+ await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
767
+ report.finish(finalStatus);
768
+ const reportPaths = await report.write(options.reportsDirectory);
769
+ if (!options.dryRun)
770
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
771
+ status(`${finalStatus}: source ${report.data.summary.pages} pages · ${report.data.summary.complete} complete · ${report.data.summary.partial} partial · ${report.data.summary.failed + report.data.summary.blocked} failed/blocked · files ${report.data.summary.resourcesComplete}/${report.data.summary.resources} complete · ${report.data.summary.errors} errors · ${report.data.summary.warnings} warnings · ${report.data.summary.degradations} degradations`);
772
+ return {
773
+ status: finalStatus,
774
+ hasErrors: report.data.summary.errors > 0,
775
+ reportPaths,
776
+ stateId: stateLocation.id,
777
+ stateFilename: stateLocation.filename,
778
+ ...(store.loadInfo.backupFilename ? { stateBackupFilename: store.loadInfo.backupFilename } : {}),
779
+ ...(output ? { output } : {}),
780
+ ...(archive ? { archive } : {}),
781
+ summary: report.data.summary,
782
+ };
657
783
  }
658
- else {
659
- await this.transferRemote({
660
- source,
661
- destination: destination,
662
- container: container,
663
- graph,
664
- options,
665
- store,
666
- report,
667
- sourceFingerprints,
668
- resourcesByPage,
669
- commentsByPage,
670
- });
671
- output = container.url;
672
- report.output('destination', container.url);
784
+ catch (error) {
785
+ const cancelled = options.signal?.aborted;
786
+ const finalStatus = cancelled ? 'cancelled' : 'failed';
787
+ const message = error instanceof Error ? error.message : String(error);
788
+ report.error(message);
789
+ await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
790
+ report.finish(finalStatus);
791
+ const reportPaths = await report.write(options.reportsDirectory);
792
+ if (!options.dryRun)
793
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
794
+ this.logger.error(`${message}. Report: ${reportPaths.html}`);
795
+ throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
673
796
  }
674
- report.finish('completed');
675
- const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings, report.data.summary.degradations);
676
- await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
677
- report.finish(finalStatus);
678
- const reportPaths = await report.write(options.reportsDirectory);
679
- if (!options.dryRun)
680
- await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
681
- status(`${finalStatus}: source ${report.data.summary.pages} pages · ${report.data.summary.complete} complete · ${report.data.summary.partial} partial · ${report.data.summary.failed + report.data.summary.blocked} failed/blocked · files ${report.data.summary.resourcesComplete}/${report.data.summary.resources} complete · ${report.data.summary.errors} errors · ${report.data.summary.warnings} warnings · ${report.data.summary.degradations} degradations`);
682
- return {
683
- status: finalStatus,
684
- hasErrors: report.data.summary.errors > 0,
685
- reportPaths,
686
- stateId: stateLocation.id,
687
- stateFilename: stateLocation.filename,
688
- ...(store.loadInfo.backupFilename ? { stateBackupFilename: store.loadInfo.backupFilename } : {}),
689
- ...(output ? { output } : {}),
690
- ...(archive ? { archive } : {}),
691
- summary: report.data.summary,
692
- };
693
797
  }
694
- catch (error) {
695
- const cancelled = options.signal?.aborted;
696
- const finalStatus = cancelled ? 'cancelled' : 'failed';
697
- const message = error instanceof Error ? error.message : String(error);
698
- report.error(message);
699
- await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
700
- report.finish(finalStatus);
701
- const reportPaths = await report.write(options.reportsDirectory);
702
- if (!options.dryRun)
703
- await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
704
- this.logger.error(`${message}. Report: ${reportPaths.html}`);
705
- throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
798
+ finally {
799
+ await store.release();
706
800
  }
707
801
  }
708
802
  async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage, commentsByPage) {
@@ -767,6 +861,7 @@ export class Migrator {
767
861
  status: pageStatus,
768
862
  resources,
769
863
  degradations: manifestPage?.warnings ?? [],
864
+ adaptations: manifestPage?.adaptations ?? [],
770
865
  ...(manifestPage?.comments ? { comments: {
771
866
  status: manifestPage.comments.status,
772
867
  ...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
@@ -798,6 +893,8 @@ export class Migrator {
798
893
  resourceStates: resources,
799
894
  ...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
800
895
  ...(manifestPage?.warnings ? { warnings: manifestPage.warnings } : {}),
896
+ ...(manifestPage?.adaptations ? { adaptations: manifestPage.adaptations } : {}),
897
+ preservedExternalReferences: manifestPage?.preservedExternalReferences ?? 0,
801
898
  commentsEnabled: options.comments ?? true,
802
899
  ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
803
900
  ...(manifestPage?.comments ? { commentState: {
@@ -917,23 +1014,7 @@ export class Migrator {
917
1014
  }
918
1015
  }
919
1016
  current ??= await destination.findArchiveComment?.(destinationPage, fingerprint);
920
- if (current && previous?.destinationFingerprint && current.fingerprint !== previous.destinationFingerprint) {
921
- const conflict = {
922
- fingerprint,
923
- destinationId: current.id,
924
- destinationFingerprint: current.fingerprint,
925
- ...counts,
926
- ...(previous.archiveResourceId ? { archiveResourceId: previous.archiveResourceId } : {}),
927
- ...(previous.archiveName ? { archiveName: previous.archiveName } : {}),
928
- ...(previous.archivePath ? { archivePath: previous.archivePath } : {}),
929
- ...(previous.archiveSha256 ? { archiveSha256: previous.archiveSha256 } : {}),
930
- status: 'conflict',
931
- lastError: 'Destination comment archive was changed manually and was preserved.',
932
- updatedAt: now(),
933
- };
934
- return conflict;
935
- }
936
- const remoteResources = await destination.listUploadedResources(destinationPage);
1017
+ let remoteResources = await destination.listUploadedResources(destinationPage);
937
1018
  const remoteMatches = async (remote, file, trustedMapping) => {
938
1019
  if (remote.size !== undefined && remote.size !== file.size)
939
1020
  return false;
@@ -957,18 +1038,37 @@ export class Migrator {
957
1038
  if (reusable)
958
1039
  return reusable;
959
1040
  const extension = path.extname(file.name);
960
- const alternativeName = `${file.name.slice(0, file.name.length - extension.length)}-${file.sha256.slice(0, 8)}${extension}`;
961
- const alternative = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === alternativeName.toLocaleLowerCase());
962
- if (alternative && await remoteMatches(alternative, file, false))
963
- return alternative;
964
- const uploadName = mapped || byName ? alternativeName : file.name;
965
- return destination.uploadResource({
966
- page: destinationPage,
967
- name: uploadName,
968
- filePath: file.path,
969
- size: file.size,
970
- ...(mimeType ? { mimeType } : {}),
971
- });
1041
+ const base = file.name.slice(0, file.name.length - extension.length);
1042
+ let lastError;
1043
+ for (let attempt = 0; attempt < 10; attempt += 1) {
1044
+ const uploadName = attempt === 0 && !mapped && !byName
1045
+ ? file.name
1046
+ : `${base}-${file.sha256.slice(0, Math.min(file.sha256.length, 8 + attempt * 2))}${extension}`;
1047
+ const occupied = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === uploadName.toLocaleLowerCase());
1048
+ if (occupied && await remoteMatches(occupied, file, false))
1049
+ return occupied;
1050
+ if (occupied)
1051
+ continue;
1052
+ try {
1053
+ const uploaded = await destination.uploadResource({
1054
+ page: destinationPage,
1055
+ name: uploadName,
1056
+ filePath: file.path,
1057
+ size: file.size,
1058
+ ...(mimeType ? { mimeType } : {}),
1059
+ });
1060
+ remoteResources.push(uploaded);
1061
+ return uploaded;
1062
+ }
1063
+ catch (error) {
1064
+ lastError = error;
1065
+ remoteResources = await destination.listUploadedResources(destinationPage);
1066
+ const uncertain = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === uploadName.toLocaleLowerCase());
1067
+ if (uncertain && await remoteMatches(uncertain, file, false))
1068
+ return uncertain;
1069
+ }
1070
+ }
1071
+ throw new Error(`Archive upload could not be reconciled after 10 deterministic names: ${lastError instanceof Error ? lastError.message : String(lastError ?? 'unknown provider response')}`);
972
1072
  };
973
1073
  const archiveResource = zip
974
1074
  ? await uploadArchive(zip, previous?.archiveResourceId, 'application/zip')
@@ -1046,7 +1146,15 @@ export class Migrator {
1046
1146
  await prepareFullArchive(`Full comment archive exceeded ${maximumInlineBytes} inline bytes and was attached as Markdown.`);
1047
1147
  }
1048
1148
  let result = current;
1049
- const requiresWrite = !result || previous?.fingerprint !== fingerprint;
1149
+ const verifies = (comment) => {
1150
+ if (!comment)
1151
+ return false;
1152
+ const verification = verifyCommentArchive(fullArchiveState.fullArchiveName ? fingerprint : inventory, comment.body, archiveResource?.name);
1153
+ if (fullArchiveState.fullArchiveName && !comment.body.includes(fullArchiveState.fullArchiveName))
1154
+ return false;
1155
+ return verification.ok && verifyDestinationContent(expectedBody, comment.body, destination.kind, page).status === 'complete';
1156
+ };
1157
+ const requiresWrite = !verifies(result);
1050
1158
  if (requiresWrite) {
1051
1159
  const write = async (body) => {
1052
1160
  if (input.signal?.aborted)
@@ -1054,7 +1162,30 @@ export class Migrator {
1054
1162
  if (!result || !destination.capabilities.updateComments || !destination.updateArchiveComment) {
1055
1163
  return destination.createArchiveComment({ page: destinationPage, body });
1056
1164
  }
1057
- return destination.updateArchiveComment({ comment: result, body });
1165
+ let currentComment = result;
1166
+ for (let attempt = 0;; attempt += 1) {
1167
+ try {
1168
+ return await destination.updateArchiveComment({ comment: currentComment, body });
1169
+ }
1170
+ catch (error) {
1171
+ if (!(error instanceof DestinationRevisionConflictError) || attempt >= REVISION_RETRY_DELAYS_MS.length)
1172
+ throw error;
1173
+ const delay = await revisionDelay(attempt, this.consistencyOptions);
1174
+ report.issue({
1175
+ code: 'destination-comment-revision-retry',
1176
+ severity: 'normalization',
1177
+ phase: 'comment',
1178
+ pageId: page.canonicalId,
1179
+ commentId: currentComment.id,
1180
+ retryable: true,
1181
+ message: `${page.title}: comment archive revision changed concurrently; retry ${attempt + 1} after ${delay} ms`,
1182
+ });
1183
+ currentComment = await destination.readArchiveComment({ page: destinationPage, commentId: currentComment.id });
1184
+ if (verifies(currentComment)) {
1185
+ return currentComment;
1186
+ }
1187
+ }
1188
+ }
1058
1189
  };
1059
1190
  const presentations = fallbackReason ? [] : ['native', 'quote', 'code'];
1060
1191
  let lastError;
@@ -1101,14 +1232,41 @@ export class Migrator {
1101
1232
  }
1102
1233
  if (!result)
1103
1234
  throw new Error('Destination comment archive was not created');
1235
+ if (requiresWrite && current && destination.capabilities.updateComments && result.id === current.id) {
1236
+ report.issue({
1237
+ code: 'destination-revision-updated',
1238
+ severity: 'normalization',
1239
+ phase: 'comment',
1240
+ pageId: page.canonicalId,
1241
+ commentId: result.id,
1242
+ retryable: false,
1243
+ message: `${page.title}: applied the current source discussion as comment version ${result.version ?? 'confirmed'}`,
1244
+ });
1245
+ }
1104
1246
  const verified = await destination.readArchiveComment({ page: destinationPage, commentId: result.id });
1105
1247
  const verification = verifyCommentArchive(fullArchiveState.fullArchiveName ? fingerprint : inventory, verified.body, archiveResource?.name);
1106
1248
  if (fullArchiveState.fullArchiveName && !verified.body.includes(fullArchiveState.fullArchiveName)) {
1107
1249
  verification.ok = false;
1108
1250
  verification.differences.push('full Markdown comment archive reference is missing');
1109
1251
  }
1252
+ const semanticVerification = verifyDestinationContent(expectedBody, verified.body, destination.kind, page);
1253
+ if (semanticVerification.status !== 'complete') {
1254
+ verification.ok = false;
1255
+ verification.differences.push(...semanticVerification.differences);
1256
+ }
1110
1257
  if (!verification.ok)
1111
1258
  throw new Error(`Comment verification failed: ${verification.differences.join('; ')}`);
1259
+ if (fallbackReason) {
1260
+ report.issue({
1261
+ code: 'comment-presentation-adapted',
1262
+ severity: 'adaptation',
1263
+ phase: 'comment',
1264
+ pageId: page.canonicalId,
1265
+ commentId: verified.id,
1266
+ retryable: false,
1267
+ message: `${page.title}: ${fallbackReason}`,
1268
+ });
1269
+ }
1112
1270
  const previousIds = result.id !== current?.id && current
1113
1271
  ? [...new Set([...(previous?.previousDestinationIds ?? []), current.id])]
1114
1272
  : previous?.previousDestinationIds;
@@ -1116,14 +1274,14 @@ export class Migrator {
1116
1274
  fingerprint,
1117
1275
  destinationId: verified.id,
1118
1276
  destinationFingerprint: verified.fingerprint,
1277
+ ...(verified.version ? { destinationVersion: verified.version } : {}),
1119
1278
  ...(verified.url ? { destinationUrl: verified.url } : {}),
1120
1279
  ...(previousIds?.length ? { previousDestinationIds: previousIds } : {}),
1121
1280
  ...archiveState,
1122
1281
  ...fullArchiveState,
1123
- status: inventory.warnings.length > 0 || fallbackReason ? 'partial' : 'complete',
1282
+ status: inventory.warnings.length > 0 ? 'partial' : 'complete',
1124
1283
  ...counts,
1125
1284
  preview: verified.body,
1126
- ...(fallbackReason ? { lastError: fallbackReason } : {}),
1127
1285
  updatedAt: now(),
1128
1286
  };
1129
1287
  pageState.comments = state;
@@ -1139,7 +1297,8 @@ export class Migrator {
1139
1297
  ...counts,
1140
1298
  ...(pageState.comments?.destinationId ? { destinationId: pageState.comments.destinationId } : {}),
1141
1299
  ...(pageState.comments?.destinationFingerprint ? { destinationFingerprint: pageState.comments.destinationFingerprint } : {}),
1142
- status: /conflict/i.test(message) ? 'conflict' : 'failed',
1300
+ ...(pageState.comments?.destinationVersion ? { destinationVersion: pageState.comments.destinationVersion } : {}),
1301
+ status: 'failed',
1143
1302
  lastError: message,
1144
1303
  updatedAt: now(),
1145
1304
  };
@@ -1148,6 +1307,16 @@ export class Migrator {
1148
1307
  async transferRemote(input) {
1149
1308
  const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage, commentsByPage } = input;
1150
1309
  const destinationPages = new Map();
1310
+ const mappedOwners = new Map();
1311
+ for (const page of Object.values(store.value.pages)) {
1312
+ const destinationId = page.destinationId;
1313
+ if (!destinationId)
1314
+ continue;
1315
+ mappedOwners.set(destinationId, [...(mappedOwners.get(destinationId) ?? []), page.sourceCanonicalId]);
1316
+ }
1317
+ const ownershipConflicts = new Map([...mappedOwners.entries()]
1318
+ .filter(([, owners]) => owners.length > 1)
1319
+ .flatMap(([destinationId, owners]) => owners.map((owner) => [owner, { destinationId, owners }])));
1151
1320
  const stagingFailures = new Map();
1152
1321
  const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
1153
1322
  const bootstrapBodies = new Map();
@@ -1208,6 +1377,7 @@ export class Migrator {
1208
1377
  ...(previous?.previousDestinationIds ? { previousDestinationIds: previous.previousDestinationIds } : {}),
1209
1378
  sourceFingerprint: sourceFingerprints.get(page.canonicalId),
1210
1379
  ...(previous?.destinationFingerprint ? { destinationFingerprint: previous.destinationFingerprint } : {}),
1380
+ ...(previous?.destinationVersion ? { destinationVersion: previous.destinationVersion } : {}),
1211
1381
  status: 'blocked',
1212
1382
  resources: blockedResourceStates(resources, previous?.resources ?? {}, cause),
1213
1383
  ...(comments ? { comments } : {}),
@@ -1232,10 +1402,16 @@ export class Migrator {
1232
1402
  continue;
1233
1403
  }
1234
1404
  try {
1405
+ const ownershipConflict = ownershipConflicts.get(page.canonicalId);
1406
+ if (ownershipConflict) {
1407
+ throw new DestinationOwnershipConflictError('duplicate-source-mapping', [ownershipConflict.destinationId, ...ownershipConflict.owners], `Multiple source pages are mapped to destination page ${ownershipConflict.destinationId}: ${ownershipConflict.owners.join(', ')}`);
1408
+ }
1235
1409
  const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
1236
1410
  const bootstrap = bootstrapBodies.get(page.canonicalId);
1237
1411
  let created;
1238
- const title = saved?.destinationTitle ?? page.title;
1412
+ const title = saved && saved.destinationTitle !== saved.sourceTitle
1413
+ ? saved.destinationTitle
1414
+ : page.title;
1239
1415
  let resourcesForState = saved?.resources ?? {};
1240
1416
  let commentsForState = saved?.comments;
1241
1417
  let previousDestinationIds = saved?.previousDestinationIds ?? [];
@@ -1285,20 +1461,18 @@ export class Migrator {
1285
1461
  message: `${page.title}: replaced unavailable destination mapping ${mapped.id} with ${created.id}`,
1286
1462
  });
1287
1463
  }
1288
- const generatedPlaceholder = created.title === saved.destinationTitle
1289
- && isLegacyTransferPlaceholder(created.content, destination.kind, cachedPage);
1290
- if (!mappingRecovered && ['complete', 'partial'].includes(saved.status) && saved.destinationFingerprint && created.fingerprint !== saved.destinationFingerprint && !generatedPlaceholder) {
1291
- throw new Error(`Destination conflict for ${page.title}: content changed since the last verified run`);
1292
- }
1293
- if (generatedPlaceholder && !options.dryRun) {
1294
- created = await destination.updatePage({
1295
- page: created,
1296
- title: created.title,
1297
- body: bootstrap.body,
1298
- ...(created.fingerprint ? { expectedFingerprint: created.fingerprint } : {}),
1464
+ if (!mappingRecovered
1465
+ && saved.destinationFingerprint
1466
+ && created.fingerprint
1467
+ && created.fingerprint !== saved.destinationFingerprint) {
1468
+ report.issue({
1469
+ code: 'destination-revision-updated',
1470
+ severity: 'normalization',
1471
+ phase: 'page',
1472
+ pageId: page.canonicalId,
1473
+ retryable: false,
1474
+ message: `${page.title}: current destination revision differs from the last confirmed state and will be reconciled from source`,
1299
1475
  });
1300
- created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
1301
- assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
1302
1476
  }
1303
1477
  if (!mappingRecovered && created.parentId !== parent.id) {
1304
1478
  const oldPage = created;
@@ -1314,12 +1488,14 @@ export class Migrator {
1314
1488
  });
1315
1489
  }
1316
1490
  else if (destination.capabilities.reparentPages && destination.reparentPage) {
1317
- created = await destination.reparentPage({
1318
- page: created,
1491
+ created = await reparentMappedPage({
1492
+ destination,
1493
+ current: created,
1319
1494
  parent,
1320
- ...(created.fingerprint ? { expectedFingerprint: created.fingerprint } : {}),
1495
+ sourcePage: cachedPage,
1496
+ consistencyOptions: this.consistencyOptions,
1497
+ report,
1321
1498
  });
1322
- created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
1323
1499
  if (created.parentId !== parent.id) {
1324
1500
  throw new Error(`Destination hierarchy verification failed for ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
1325
1501
  }
@@ -1410,11 +1586,12 @@ export class Migrator {
1410
1586
  destinationId: created.id,
1411
1587
  destinationCanonicalId: created.canonicalId,
1412
1588
  destinationLocator: created.url,
1413
- destinationTitle: created.title,
1589
+ destinationTitle: saved?.destinationId && !mappingRecovered ? title : created.title,
1414
1590
  ...(previousDestinationIds.length > 0 ? { previousDestinationIds } : {}),
1415
1591
  sourceFingerprint: sourceFingerprints.get(page.canonicalId),
1416
1592
  ...(created.fingerprint ? { destinationFingerprint: created.fingerprint } : {}),
1417
- status: options.dryRun ? 'planned' : saved?.status ?? 'skeleton-created',
1593
+ ...(created.version ? { destinationVersion: created.version } : {}),
1594
+ status: options.dryRun ? 'planned' : 'skeleton-created',
1418
1595
  resources: resourcesForState,
1419
1596
  ...(commentsForState ? { comments: commentsForState } : {}),
1420
1597
  degradations: saved?.degradations ?? [],
@@ -1427,8 +1604,9 @@ export class Migrator {
1427
1604
  if (options.signal?.aborted)
1428
1605
  throw options.signal.reason ?? error;
1429
1606
  const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
1430
- const failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
1431
- const failureCode = failureStatus === 'conflict' ? 'destination-conflict' : 'destination-page-staging-failed';
1607
+ const ownershipConflict = error instanceof DestinationOwnershipConflictError ? error : undefined;
1608
+ const failureStatus = ownershipConflict ? 'conflict' : 'failed';
1609
+ const failureCode = ownershipConflict ? 'destination-ownership-ambiguity' : 'destination-page-staging-failed';
1432
1610
  stagingFailures.set(page.canonicalId, {
1433
1611
  status: failureStatus,
1434
1612
  message,
@@ -1446,13 +1624,14 @@ export class Migrator {
1446
1624
  });
1447
1625
  const savedState = store.value.pages[page.canonicalId];
1448
1626
  if (!options.dryRun) {
1627
+ const { conflictEvidence: _staleConflictEvidence, ...baseState } = savedState ?? {};
1449
1628
  const cause = { blockedByPageId: page.canonicalId, blockedByCode: failureCode, blockedReason: message };
1450
1629
  const resources = resourcesByPage.get(page.canonicalId) ?? [];
1451
1630
  const comments = (options.comments ?? true)
1452
1631
  ? blockedCommentState(commentsByPage.get(page.canonicalId), savedState?.comments, cause)
1453
1632
  : undefined;
1454
1633
  await store.setPage({
1455
- ...(savedState ?? {}),
1634
+ ...baseState,
1456
1635
  sourceId: page.id,
1457
1636
  sourceCanonicalId: page.canonicalId,
1458
1637
  sourceLocator: page.locator,
@@ -1464,6 +1643,8 @@ export class Migrator {
1464
1643
  resources: blockedResourceStates(resources, savedState?.resources ?? {}, cause),
1465
1644
  ...(comments ? { comments } : {}),
1466
1645
  degradations: savedState?.degradations ?? [],
1646
+ adaptations: savedState?.adaptations ?? [],
1647
+ ...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
1467
1648
  lastError: message,
1468
1649
  updatedAt: new Date().toISOString(),
1469
1650
  });
@@ -1503,6 +1684,7 @@ export class Migrator {
1503
1684
  commentsEnabled: options.comments ?? true,
1504
1685
  ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
1505
1686
  ...(commentState ? { commentState } : {}),
1687
+ ...(failedState?.conflictEvidence ? { conflictEvidence: failedState.conflictEvidence } : {}),
1506
1688
  blockedBy,
1507
1689
  error: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
1508
1690
  }));
@@ -1641,8 +1823,8 @@ export class Migrator {
1641
1823
  updateProgress(`reconciled ${resource.name}`);
1642
1824
  continue;
1643
1825
  }
1644
- const updateExisting = existing?.status === 'complete' && mappedRemote ? mappedRemote : undefined;
1645
- const destinationName = updateExisting?.name ?? nameAllocator.allocate(resource, existing?.status === 'complete' && !nameAllocator.isOccupied(existing.destinationName) ? existing.destinationName : undefined);
1826
+ const updateExisting = destination.kind === 'confluence' && existing?.status === 'complete' && mappedRemote ? mappedRemote : undefined;
1827
+ let destinationName = updateExisting?.name ?? nameAllocator.allocate(resource, existing?.status === 'complete' && !nameAllocator.isOccupied(existing.destinationName) ? existing.destinationName : undefined);
1646
1828
  state.resources[resource.id] = {
1647
1829
  sourceId: resource.id,
1648
1830
  sourceName: resource.name,
@@ -1655,9 +1837,19 @@ export class Migrator {
1655
1837
  updatedAt: new Date().toISOString(),
1656
1838
  };
1657
1839
  await store.setPage({ ...state, updatedAt: new Date().toISOString() });
1658
- const matchingName = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
1659
- let result = matchingName && await remoteMatches(matchingName, downloaded) ? matchingName : undefined;
1660
- if (!result) {
1840
+ let result;
1841
+ let lastUploadError;
1842
+ for (let attempt = 0; attempt < 10 && !result; attempt += 1) {
1843
+ const matchingName = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
1844
+ if (matchingName && await remoteMatches(matchingName, downloaded)) {
1845
+ result = matchingName;
1846
+ break;
1847
+ }
1848
+ if (matchingName) {
1849
+ nameAllocator.reserve(matchingName.name);
1850
+ destinationName = nameAllocator.allocate(resource);
1851
+ continue;
1852
+ }
1661
1853
  try {
1662
1854
  result = await destination.uploadResource({
1663
1855
  page: destinationPage,
@@ -1665,26 +1857,25 @@ export class Migrator {
1665
1857
  filePath: downloaded.path,
1666
1858
  size: downloaded.size,
1667
1859
  ...(mimeType ? { mimeType } : {}),
1668
- ...(updateExisting ? { existingId: updateExisting.id } : {}),
1860
+ ...(attempt === 0 && updateExisting ? { existingId: updateExisting.id } : {}),
1669
1861
  });
1670
1862
  }
1671
1863
  catch (uploadError) {
1864
+ lastUploadError = uploadError;
1672
1865
  remoteResources = await destination.listUploadedResources(destinationPage);
1866
+ for (const remote of remoteResources)
1867
+ nameAllocator.reserve(remote.name);
1673
1868
  const uncertain = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
1674
- if (uncertain && await remoteMatches(uncertain, downloaded))
1869
+ if (uncertain && await remoteMatches(uncertain, downloaded)) {
1675
1870
  result = uncertain;
1676
- else {
1677
- const retryName = nameAllocator.allocate(resource);
1678
- result = await destination.uploadResource({
1679
- page: destinationPage,
1680
- name: retryName,
1681
- filePath: downloaded.path,
1682
- size: downloaded.size,
1683
- ...(mimeType ? { mimeType } : {}),
1684
- }).catch((retryError) => { throw new Error(`${retryError instanceof Error ? retryError.message : String(retryError)} (initial upload: ${uploadError instanceof Error ? uploadError.message : String(uploadError)})`); });
1871
+ break;
1685
1872
  }
1873
+ destinationName = nameAllocator.allocate(resource);
1686
1874
  }
1687
1875
  }
1876
+ if (!result) {
1877
+ throw new Error(`Resource upload could not be reconciled after 10 deterministic names: ${lastUploadError instanceof Error ? lastUploadError.message : String(lastUploadError ?? 'unknown provider response')}`);
1878
+ }
1688
1879
  if (!remoteResources.some((item) => item.id === result.id))
1689
1880
  remoteResources.push(result);
1690
1881
  setSourceResourceBinding(bindings, cachedPage, resources, resource, { kind: 'uploaded', resource: result });
@@ -1767,31 +1958,63 @@ export class Migrator {
1767
1958
  sha256: sha256(cachedPage.rawContent),
1768
1959
  };
1769
1960
  try {
1770
- const knownSnapshot = remoteResources.find((item) => item.id === existingSnapshot?.destinationId)
1771
- ?? remoteResources.find((item) => item.name.toLocaleLowerCase() === snapshotName.toLocaleLowerCase());
1772
- let snapshot = knownSnapshot && await remoteMatches(knownSnapshot, snapshotFile, true)
1773
- ? knownSnapshot
1961
+ const mappedSnapshot = remoteResources.find((item) => item.id === existingSnapshot?.destinationId);
1962
+ let snapshot = mappedSnapshot && await remoteMatches(mappedSnapshot, snapshotFile, true)
1963
+ ? mappedSnapshot
1774
1964
  : undefined;
1775
- if (!snapshot) {
1965
+ const sameNameSnapshot = remoteResources.find((item) => item.name.toLocaleLowerCase() === snapshotName.toLocaleLowerCase());
1966
+ if (!snapshot && sameNameSnapshot && await remoteMatches(sameNameSnapshot, snapshotFile, false)) {
1967
+ snapshot = sameNameSnapshot;
1968
+ }
1969
+ const snapshotResource = {
1970
+ provider: cachedPage.provider,
1971
+ id: snapshotKey,
1972
+ pageId: cachedPage.id,
1973
+ name: snapshotName,
1974
+ preferredName: snapshotName,
1975
+ kind: 'attachment',
1976
+ };
1977
+ let destinationName = mappedSnapshot && destination.kind === 'confluence'
1978
+ ? mappedSnapshot.name
1979
+ : nameAllocator.allocate(snapshotResource);
1980
+ let lastUploadError;
1981
+ for (let attempt = 0; attempt < 10 && !snapshot; attempt += 1) {
1982
+ const occupied = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
1983
+ if (occupied && await remoteMatches(occupied, snapshotFile, occupied.id === mappedSnapshot?.id)) {
1984
+ snapshot = occupied;
1985
+ break;
1986
+ }
1987
+ if (occupied && occupied.id !== mappedSnapshot?.id) {
1988
+ nameAllocator.reserve(occupied.name);
1989
+ destinationName = nameAllocator.allocate(snapshotResource);
1990
+ continue;
1991
+ }
1776
1992
  try {
1777
1993
  snapshot = await destination.uploadResource({
1778
1994
  page: destinationPage,
1779
- name: snapshotName,
1995
+ name: destinationName,
1780
1996
  filePath: snapshotPath,
1781
1997
  size: snapshotFile.size,
1782
1998
  mimeType: page.rawFormat === 'yfm' ? 'text/markdown' : 'application/xml',
1783
- ...(knownSnapshot ? { existingId: knownSnapshot.id } : {}),
1999
+ ...(attempt === 0 && mappedSnapshot && destination.kind === 'confluence' ? { existingId: mappedSnapshot.id } : {}),
1784
2000
  });
1785
2001
  }
1786
2002
  catch (uploadError) {
2003
+ lastUploadError = uploadError;
1787
2004
  remoteResources = await destination.listUploadedResources(destinationPage);
1788
- const uncertain = remoteResources.find((item) => item.name.toLocaleLowerCase() === snapshotName.toLocaleLowerCase());
1789
- if (uncertain && await remoteMatches(uncertain, snapshotFile, true))
2005
+ for (const remote of remoteResources)
2006
+ nameAllocator.reserve(remote.name);
2007
+ const uncertain = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
2008
+ if (uncertain && await remoteMatches(uncertain, snapshotFile, uncertain.id === mappedSnapshot?.id)) {
1790
2009
  snapshot = uncertain;
1791
- else
1792
- throw uploadError;
2010
+ break;
2011
+ }
2012
+ destinationName = nameAllocator.allocate(snapshotResource);
1793
2013
  }
1794
2014
  }
2015
+ if (!snapshot) {
2016
+ throw new Error(`Raw snapshot upload could not be reconciled after 10 deterministic names: ${lastUploadError instanceof Error ? lastUploadError.message : String(lastUploadError ?? 'unknown provider response')}`);
2017
+ }
1795
2018
  if (!remoteResources.some((item) => item.id === snapshot.id))
1796
2019
  remoteResources.push(snapshot);
1797
2020
  state.resources[snapshotKey] = {
@@ -1831,30 +2054,34 @@ export class Migrator {
1831
2054
  state.degradations = [...new Set([...rendered.warnings, ...resourceProblems])];
1832
2055
  }
1833
2056
  let verified = destinationPage;
2057
+ let verificationAdaptations = [];
1834
2058
  let verificationDegradations = [];
1835
2059
  if (!options.dryRun) {
1836
2060
  const current = await verifyConfirmedDestinationPage(destination, destinationPage, this.consistencyOptions);
1837
- if (destinationPage.contentFingerprint
1838
- && current.contentFingerprint
1839
- && current.contentFingerprint !== destinationPage.contentFingerprint) {
1840
- const normalization = verifyDestinationContent(destinationPage.content ?? bootstrap.body, current.content, destination.kind, cachedPage);
1841
- if (normalization.status !== 'complete') {
1842
- throw new Error(`Destination conflict for ${page.title}: content changed while resources were transferred`);
1843
- }
1844
- rendered.normalizations.push('Destination normalized page markup without semantic content changes');
1845
- }
1846
- verified = await destination.updatePage({
1847
- page: current,
1848
- title: current.title,
2061
+ const update = await updateMappedPage({
2062
+ destination,
2063
+ current,
2064
+ title: state.destinationTitle,
1849
2065
  body: rendered.body,
1850
- ...(current.fingerprint ? { expectedFingerprint: current.fingerprint } : {}),
2066
+ sourcePage: cachedPage,
2067
+ consistencyOptions: this.consistencyOptions,
2068
+ report,
1851
2069
  });
1852
- if (verified.fingerprint) {
1853
- state.destinationFingerprint = verified.fingerprint;
1854
- await store.setPage({ ...state, updatedAt: new Date().toISOString() });
2070
+ verified = update.page;
2071
+ if (update.normalized)
2072
+ rendered.normalizations.push('Destination normalized page markup without semantic content changes');
2073
+ if (update.updated) {
2074
+ report.issue({
2075
+ code: 'destination-revision-updated',
2076
+ severity: 'normalization',
2077
+ phase: 'page',
2078
+ pageId: page.canonicalId,
2079
+ retryable: false,
2080
+ message: `${page.title}: applied the current source representation as destination version ${verified.version ?? 'confirmed'}`,
2081
+ });
1855
2082
  }
1856
- verified = await verifyConfirmedDestinationPage(destination, verified, this.consistencyOptions);
1857
2083
  const verification = assertDestinationContent(rendered.body, verified.content, destination.kind, cachedPage);
2084
+ verificationAdaptations = verification.adaptations;
1858
2085
  verificationDegradations = verification.status === 'partial' ? verification.differences : [];
1859
2086
  }
1860
2087
  const commentInventory = commentsByPage.get(page.canonicalId);
@@ -1901,7 +2128,10 @@ export class Migrator {
1901
2128
  });
1902
2129
  }
1903
2130
  const commentPartial = !['complete', 'skipped', 'planned'].includes(commentState.status);
1904
- const partial = rendered.degraded || resourceProblems.length > 0 || verificationDegradations.length > 0 || commentPartial;
2131
+ const renderedDegradations = rendered.notices
2132
+ .filter((notice) => notice.kind === 'degradation')
2133
+ .map((notice) => notice.message);
2134
+ const partial = renderedDegradations.length > 0 || resourceProblems.length > 0 || verificationDegradations.length > 0 || commentPartial;
1905
2135
  const lastResourceProblem = resourceProblems.at(-1);
1906
2136
  const clearedState = { ...state };
1907
2137
  delete clearedState.lastError;
@@ -1912,9 +2142,11 @@ export class Migrator {
1912
2142
  destinationLocator: verified.url,
1913
2143
  destinationTitle: verified.title,
1914
2144
  ...(verified.fingerprint ? { destinationFingerprint: verified.fingerprint } : {}),
2145
+ ...(verified.version ? { destinationVersion: verified.version } : {}),
1915
2146
  status: options.dryRun ? 'planned' : partial ? 'partial' : 'complete',
1916
2147
  comments: commentState,
1917
- degradations: [...new Set([...rendered.warnings, ...resourceProblems, ...verificationDegradations])],
2148
+ degradations: [...new Set([...renderedDegradations, ...resourceProblems, ...verificationDegradations])],
2149
+ adaptations: [...new Set([...rendered.adaptations, ...verificationAdaptations])],
1918
2150
  ...(lastResourceProblem ? { lastError: lastResourceProblem } : {}),
1919
2151
  updatedAt: new Date().toISOString(),
1920
2152
  };
@@ -1936,6 +2168,8 @@ export class Migrator {
1936
2168
  resourceFallbacks: fallbackDetails,
1937
2169
  warnings: [...rendered.warnings, ...verificationDegradations],
1938
2170
  normalizations: rendered.normalizations,
2171
+ adaptations: [...rendered.adaptations, ...verificationAdaptations],
2172
+ preservedExternalReferences: rendered.preservedExternalReferences,
1939
2173
  commentsEnabled: options.comments ?? true,
1940
2174
  ...(commentInventory ? { commentInventory } : {}),
1941
2175
  commentState,
@@ -1943,7 +2177,8 @@ export class Migrator {
1943
2177
  }
1944
2178
  catch (error) {
1945
2179
  const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
1946
- let failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
2180
+ const ownershipConflict = error instanceof DestinationOwnershipConflictError ? error : undefined;
2181
+ let failureStatus = ownershipConflict ? 'conflict' : 'failed';
1947
2182
  let actual;
1948
2183
  if (failureStatus !== 'conflict' && !options.dryRun) {
1949
2184
  try {
@@ -1956,7 +2191,7 @@ export class Migrator {
1956
2191
  }
1957
2192
  }
1958
2193
  report.issue({
1959
- code: failureStatus === 'conflict' ? 'destination-conflict' : 'page-transfer-failed',
2194
+ code: ownershipConflict ? 'destination-ownership-ambiguity' : 'page-transfer-failed',
1960
2195
  severity: 'error',
1961
2196
  phase: 'page',
1962
2197
  pageId: page.canonicalId,
@@ -1966,7 +2201,9 @@ export class Migrator {
1966
2201
  await store.setPage({
1967
2202
  ...state,
1968
2203
  ...(actual?.fingerprint ? { destinationFingerprint: actual.fingerprint } : {}),
2204
+ ...(actual?.version ? { destinationVersion: actual.version } : {}),
1969
2205
  status: failureStatus,
2206
+ ...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
1970
2207
  lastError: message,
1971
2208
  updatedAt: new Date().toISOString(),
1972
2209
  });
@@ -1989,6 +2226,7 @@ export class Migrator {
1989
2226
  commentsEnabled: options.comments ?? true,
1990
2227
  ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
1991
2228
  ...(state.comments ? { commentState: state.comments } : {}),
2229
+ ...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
1992
2230
  }), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
1993
2231
  }
1994
2232
  finally {