sb-mig 6.4.0-beta.1 → 6.4.1-beta.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.
@@ -2,7 +2,7 @@ import path from "path";
2
2
  import chalk from "chalk";
3
3
  import { discoverMigrationConfig, discoverStories, LOOKUP_TYPE, SCOPE, } from "../../cli/utils/discover.js";
4
4
  import storyblokConfig from "../../config/config.js";
5
- import { createAndSaveToFile, getFileContentWithRequire, getFilesContentWithRequire, listFilesInDir, readFile, } from "../../utils/files.js";
5
+ import { createAndSaveToFile, getFileContentWithRequire, getFilesContentWithRequire, listFilesInDir, readFile, readJsonArrayStreamed, } from "../../utils/files.js";
6
6
  import Logger from "../../utils/logger.js";
7
7
  import { modifyOrCreateAppliedMigrationsFile } from "../../utils/migrations.js";
8
8
  import { isObjectEmpty } from "../../utils/object-utils.js";
@@ -1279,6 +1279,13 @@ const readMigrationJson = async (config, filename) => {
1279
1279
  throw new Error(`Migration artifact is not valid JSON: migrations/${filename}`);
1280
1280
  }
1281
1281
  };
1282
+ /**
1283
+ * Read a migration artifact that is a JSON array (e.g. the changed-items or
1284
+ * after-full snapshots). These can exceed V8's ~512 MB max string length on
1285
+ * large spaces, so they are streamed element by element instead of read into a
1286
+ * single string (which would throw ERR_STRING_TOO_LONG).
1287
+ */
1288
+ const readMigrationJsonArray = (config, filename) => readJsonArrayStreamed(`${config.sbmigWorkingDirectory}/migrations/${filename}`);
1282
1289
  /**
1283
1290
  * Discover the dry-run `continue` manifest in the migrations folder, load the
1284
1291
  * artifacts it points at, and reconstruct the exact inputs `finalizeMigration`
@@ -1317,7 +1324,7 @@ export const prepareContinueMigration = async ({ manifestFileName }, config) =>
1317
1324
  if (!artifacts?.changedItems) {
1318
1325
  throw new Error(`Manifest ${chosen} is missing the changed-items artifact reference.`);
1319
1326
  }
1320
- const changedItems = (await readMigrationJson(config, artifacts.changedItems));
1327
+ const changedItems = await readMigrationJsonArray(config, artifacts.changedItems);
1321
1328
  const pipelineSummary = artifacts.pipelineSummary
1322
1329
  ? await readMigrationJson(config, artifacts.pipelineSummary)
1323
1330
  : null;
@@ -1337,7 +1344,7 @@ export const prepareContinueMigration = async ({ manifestFileName }, config) =>
1337
1344
  missingPublishedLayerRecords: [],
1338
1345
  };
1339
1346
  const publishedFinalItems = artifacts.publishedAfterFull
1340
- ? (await readMigrationJson(config, artifacts.publishedAfterFull))
1347
+ ? await readMigrationJsonArray(config, artifacts.publishedAfterFull)
1341
1348
  : [];
1342
1349
  const publishedChangedIdSet = new Set((dirty.publishedChangedIds ?? []).map(String));
1343
1350
  publishedLayerPipelineResult = {
@@ -1348,7 +1355,7 @@ export const prepareContinueMigration = async ({ manifestFileName }, config) =>
1348
1355
  };
1349
1356
  }
1350
1357
  const draftFinalItems = artifacts.draftAfterFull
1351
- ? (await readMigrationJson(config, artifacts.draftAfterFull))
1358
+ ? await readMigrationJsonArray(config, artifacts.draftAfterFull)
1352
1359
  : changedItems;
1353
1360
  const pipelineResult = {
1354
1361
  changedItems,
@@ -314,7 +314,84 @@ const getValidMappedTargetStory = async ({ sourceStory, targetStoryId, targetFul
314
314
  Logger.warning(`Ignoring stale story manifest mapping for '${sourceStory.full_slug ?? sourceStory.slug}' because target story '${targetStoryId}' was not found at the expected target path in space '${targetSpace}'.`);
315
315
  return undefined;
316
316
  };
317
- const assertStoryUpdateSucceeded = ({ result, sourceStory, targetStoryId, targetSpace, }) => {
317
+ // Walk a story's content blok tree depth-first, invoking `visit` for every blok
318
+ // (object carrying a `component`) with its path, _uid, enclosing field and the
319
+ // component it is nested inside. Shared by the failure diagnostic and the
320
+ // dry-run compatibility check.
321
+ const walkContentBloks = (content, visit) => {
322
+ const walk = (value, path, field, parentComponent) => {
323
+ if (Array.isArray(value)) {
324
+ value.forEach((item, index) => {
325
+ walk(item, `${path}[${index}]`, field, parentComponent);
326
+ });
327
+ return;
328
+ }
329
+ if (!value || typeof value !== "object") {
330
+ return;
331
+ }
332
+ const component = typeof value.component === "string" ? value.component : undefined;
333
+ if (component) {
334
+ visit({
335
+ path,
336
+ uid: typeof value._uid === "string" ? value._uid : undefined,
337
+ component,
338
+ field,
339
+ parentComponent,
340
+ });
341
+ }
342
+ const nextParentComponent = component ?? parentComponent;
343
+ for (const [key, child] of Object.entries(value)) {
344
+ if (key === "component" || key === "_uid") {
345
+ continue;
346
+ }
347
+ walk(child, path ? `${path}.${key}` : key, key, nextParentComponent);
348
+ }
349
+ };
350
+ walk(content, "content", undefined, undefined);
351
+ };
352
+ // Storyblok answers a schema violation with a 422 whose body reads like
353
+ // "The component(s) sb-content-group are not allowed in the field content".
354
+ // Parse out the offending component name(s) and the field they were rejected in.
355
+ const parseDisallowedComponentError = (responseText) => {
356
+ if (typeof responseText !== "string") {
357
+ return undefined;
358
+ }
359
+ const match = responseText.match(/component\(s\)\s+(.+?)\s+are not allowed in the field\s+([^\s.]+)/i);
360
+ if (!match || !match[1]) {
361
+ return undefined;
362
+ }
363
+ const components = match[1]
364
+ .split(",")
365
+ .map((component) => component.trim())
366
+ .filter(Boolean);
367
+ if (components.length === 0) {
368
+ return undefined;
369
+ }
370
+ return { components, field: match[2] };
371
+ };
372
+ // Record every place the given component(s) appear in a story's content, with
373
+ // the path, _uid and enclosing field so a failing copy can be traced back to
374
+ // the exact blok in the source story.
375
+ const collectComponentPaths = (content, targetComponents) => {
376
+ const matches = [];
377
+ walkContentBloks(content, (blok) => {
378
+ if (targetComponents.has(blok.component)) {
379
+ matches.push(blok);
380
+ }
381
+ });
382
+ return matches;
383
+ };
384
+ const describeDisallowedComponentMatches = (matches) => matches
385
+ .map((match) => {
386
+ const uidLabel = match.uid ? ` (_uid ${match.uid})` : "";
387
+ const fieldLabel = match.field ? ` in field '${match.field}'` : "";
388
+ const parentLabel = match.parentComponent
389
+ ? ` of component '${match.parentComponent}'`
390
+ : "";
391
+ return ` - '${match.component}' at ${match.path}${fieldLabel}${parentLabel}${uidLabel}`;
392
+ })
393
+ .join("\n");
394
+ const assertStoryUpdateSucceeded = ({ result, sourceStory, targetStoryId, targetSpace, content, }) => {
318
395
  if (result?.ok !== false) {
319
396
  return;
320
397
  }
@@ -324,7 +401,23 @@ const assertStoryUpdateSucceeded = ({ result, sourceStory, targetStoryId, target
324
401
  const responseLabel = result.response
325
402
  ? ` Response: ${result.response}`
326
403
  : "";
327
- throw new Error(`Failed to update copied story '${sourceStory.full_slug ?? sourceStory.slug}' in target space '${targetSpace}' (target story id ${targetStoryId}, ${statusLabel}).${responseLabel}`);
404
+ let disallowedLabel = "";
405
+ const disallowed = parseDisallowedComponentError(result.response);
406
+ if (disallowed && content) {
407
+ const matches = collectComponentPaths(content, new Set(disallowed.components));
408
+ const fieldLabel = disallowed.field
409
+ ? ` in field '${disallowed.field}'`
410
+ : "";
411
+ if (matches.length > 0) {
412
+ const details = describeDisallowedComponentMatches(matches);
413
+ disallowedLabel = `\nDisallowed component(s) [${disallowed.components.join(", ")}]${fieldLabel} located in source story content at:\n${details}`;
414
+ Logger.warning(`Story '${sourceStory.full_slug ?? sourceStory.slug}' (source id ${sourceStory.id}, target id ${targetStoryId}) rejected because component(s) [${disallowed.components.join(", ")}]${fieldLabel} are not allowed in the target space schema. Found at:\n${details}`);
415
+ }
416
+ else {
417
+ disallowedLabel = `\nComponent(s) [${disallowed.components.join(", ")}]${fieldLabel} are not allowed in the target space schema, but were not located in the rewritten content (they may live in a nested/published layer).`;
418
+ }
419
+ }
420
+ throw new Error(`Failed to update copied story '${sourceStory.full_slug ?? sourceStory.slug}' in target space '${targetSpace}' (source id ${sourceStory.id}, target story id ${targetStoryId}, ${statusLabel}).${responseLabel}${disallowedLabel}`);
328
421
  };
329
422
  const isStoryUpdateNotFound = (result) => result?.ok === false && Number(result.status) === 404;
330
423
  const buildCopyPlan = (tree, destination) => {
@@ -398,6 +491,37 @@ const buildDryRunWarnings = ({ conflicts, withAssets, }) => [
398
491
  },
399
492
  ]),
400
493
  ];
494
+ const uniqueSorted = (values) => [...new Set(values)].filter(Boolean).sort();
495
+ const summarizeComponentCompatibility = (checked, findings) => ({
496
+ checked,
497
+ missingComponents: uniqueSorted(findings
498
+ .filter((finding) => finding.reason === "missing_in_target")
499
+ .map((finding) => finding.component)),
500
+ disallowedInFieldComponents: uniqueSorted(findings
501
+ .filter((finding) => finding.reason === "not_allowed_in_field")
502
+ .map((finding) => finding.component)),
503
+ findings,
504
+ });
505
+ const buildComponentCompatibilityWarnings = (componentCompatibility) => {
506
+ if (!componentCompatibility ||
507
+ componentCompatibility.findings.length === 0) {
508
+ return [];
509
+ }
510
+ const warnings = [];
511
+ if (componentCompatibility.missingComponents.length > 0) {
512
+ warnings.push({
513
+ code: "component_missing_in_target",
514
+ message: `Component(s) missing from the target space schema (story updates will fail with a 422 until they are synced): ${componentCompatibility.missingComponents.join(", ")}.`,
515
+ });
516
+ }
517
+ if (componentCompatibility.disallowedInFieldComponents.length > 0) {
518
+ warnings.push({
519
+ code: "component_not_allowed_in_field",
520
+ message: `Component(s) used in a field the target space schema restricts (story updates will fail with a 422): ${componentCompatibility.disallowedInFieldComponents.join(", ")}.`,
521
+ });
522
+ }
523
+ return warnings;
524
+ };
401
525
  const quoteCommandArg = (value) => /^[a-zA-Z0-9_./:-]+$/.test(value) ? value : JSON.stringify(value);
402
526
  const buildCopyCommand = ({ sourceSpace, targetSpace, selection, destination, withAssets, dryRun, outputPath, }) => {
403
527
  const args = [
@@ -549,9 +673,12 @@ const buildAssetReferenceSummary = ({ graph, sourceSpace, }) => {
549
673
  .sort((left, right) => left.spaceId.localeCompare(right.spaceId)),
550
674
  };
551
675
  };
552
- const buildCopyDryRunReport = ({ sourceSpace, targetSpace, selection, destination, withAssets, input, plan, conflicts, graph, outputPath, }) => {
676
+ const buildCopyDryRunReport = ({ sourceSpace, targetSpace, selection, destination, withAssets, input, plan, conflicts, graph, componentCompatibility, outputPath, }) => {
553
677
  const items = withConflictFlags(plan, conflicts);
554
- const warnings = buildDryRunWarnings({ conflicts, withAssets });
678
+ const warnings = [
679
+ ...buildDryRunWarnings({ conflicts, withAssets }),
680
+ ...buildComponentCompatibilityWarnings(componentCompatibility),
681
+ ];
555
682
  const graphSummary = graph ? summarizeCopyGraph(graph) : undefined;
556
683
  const assetReferencesMapped = graph?.assetReferences.filter((reference) => reference.status === "mapped").length ?? 0;
557
684
  const assetReferencesPlanned = graph?.assetReferences.filter((reference) => reference.status === "planned").length ?? 0;
@@ -604,10 +731,12 @@ const buildCopyDryRunReport = ({ sourceSpace, targetSpace, selection, destinatio
604
731
  conflicts: conflicts.length,
605
732
  warnings: warnings.length + (graphSummary?.warnings ?? 0),
606
733
  errors: graphSummary?.errors ?? 0,
734
+ componentIssues: componentCompatibility?.findings.length ?? 0,
607
735
  },
608
736
  items,
609
737
  ...(graph ? { graph } : {}),
610
738
  ...(assetReferenceSummary ? { assetReferenceSummary } : {}),
739
+ ...(componentCompatibility ? { componentCompatibility } : {}),
611
740
  warnings,
612
741
  errors: graph?.errors ?? [],
613
742
  limitations,
@@ -962,6 +1091,96 @@ const buildComponentSchemaRegistry = async (sourceSpace) => {
962
1091
  .filter((component) => component?.name && component?.schema)
963
1092
  .map((component) => [component.name, component.schema]));
964
1093
  };
1094
+ // Build a validator from the TARGET space component schemas so the dry-run can
1095
+ // predict the "component(s) X are not allowed in the field Y" 422 that the
1096
+ // Management API returns at write time. Two problems are detected:
1097
+ // - a component used in a source story does not exist in the target space
1098
+ // - a component sits in a bloks field whose target schema restricts the
1099
+ // allowed components and does not include it
1100
+ const buildTargetComponentValidator = async (targetSpace) => {
1101
+ const components = await managementApi.components.getAllComponents({
1102
+ ...apiConfig,
1103
+ spaceId: targetSpace,
1104
+ });
1105
+ const list = Array.isArray(components) ? components : [];
1106
+ const targetComponentNames = new Set(list.map((component) => component?.name).filter(Boolean));
1107
+ const schemaByName = new Map(list
1108
+ .filter((component) => component?.name)
1109
+ .map((component) => [component.name, component.schema ?? {}]));
1110
+ const componentsByGroupUuid = new Map();
1111
+ for (const component of list) {
1112
+ const groupUuid = component?.component_group_uuid;
1113
+ if (!component?.name || !groupUuid) {
1114
+ continue;
1115
+ }
1116
+ const existing = componentsByGroupUuid.get(String(groupUuid)) ?? new Set();
1117
+ existing.add(component.name);
1118
+ componentsByGroupUuid.set(String(groupUuid), existing);
1119
+ }
1120
+ // Allowed child components for a restricted bloks field, or undefined when
1121
+ // the field is unrestricted or cannot be resolved with confidence (which
1122
+ // keeps the dry-run free of false positives).
1123
+ const resolveAllowedComponents = (parentComponent, field) => {
1124
+ if (!parentComponent || !field) {
1125
+ return undefined;
1126
+ }
1127
+ const fieldDef = schemaByName.get(parentComponent)?.[field];
1128
+ if (!fieldDef || fieldDef.type !== "bloks") {
1129
+ return undefined;
1130
+ }
1131
+ if (fieldDef.restrict_components !== true) {
1132
+ return undefined;
1133
+ }
1134
+ // Tag-based whitelists reference internal tag ids we do not resolve.
1135
+ if (fieldDef.restrict_type === "tags") {
1136
+ return undefined;
1137
+ }
1138
+ const allowed = new Set();
1139
+ for (const name of fieldDef.component_whitelist ?? []) {
1140
+ if (typeof name === "string") {
1141
+ allowed.add(name);
1142
+ }
1143
+ }
1144
+ for (const groupUuid of fieldDef.component_group_whitelist ?? []) {
1145
+ const names = componentsByGroupUuid.get(String(groupUuid));
1146
+ if (names) {
1147
+ names.forEach((name) => allowed.add(name));
1148
+ }
1149
+ }
1150
+ return allowed;
1151
+ };
1152
+ return {
1153
+ // With no components fetched we cannot validate anything; skip instead
1154
+ // of reporting every component as missing.
1155
+ canValidate: targetComponentNames.size > 0,
1156
+ validateStory(story) {
1157
+ const findings = [];
1158
+ walkContentBloks(story?.content, (blok) => {
1159
+ const base = {
1160
+ sourceStoryId: Number(story?.id),
1161
+ sourceFullSlug: String(story?.full_slug ?? story?.slug ?? ""),
1162
+ component: blok.component,
1163
+ path: blok.path,
1164
+ uid: blok.uid,
1165
+ field: blok.field,
1166
+ parentComponent: blok.parentComponent,
1167
+ };
1168
+ if (!targetComponentNames.has(blok.component)) {
1169
+ findings.push({ ...base, reason: "missing_in_target" });
1170
+ return;
1171
+ }
1172
+ const allowed = resolveAllowedComponents(blok.parentComponent, blok.field);
1173
+ if (allowed && !allowed.has(blok.component)) {
1174
+ findings.push({
1175
+ ...base,
1176
+ reason: "not_allowed_in_field",
1177
+ });
1178
+ }
1179
+ });
1180
+ return findings;
1181
+ },
1182
+ };
1183
+ };
965
1184
  const collectAssetFolderAncestors = ({ assets, assetFolders, }) => {
966
1185
  const folderById = new Map(assetFolders.map((folder) => [Number(folder.id), folder]));
967
1186
  const selectedFolderIds = new Set();
@@ -1227,6 +1446,7 @@ const rewriteCopiedStoryContents = async ({ tree, realParentId, sourceStoryById,
1227
1446
  const schemas = await buildComponentSchemaRegistry(sourceSpace);
1228
1447
  let updatedStories = 0;
1229
1448
  let rewrittenReferences = 0;
1449
+ const failures = [];
1230
1450
  const writeStoryMapping = async ({ sourceStory, targetStory, targetFullSlug, action, }) => {
1231
1451
  const entry = {
1232
1452
  type: "story",
@@ -1294,11 +1514,26 @@ const rewriteCopiedStoryContents = async ({ tree, realParentId, sourceStoryById,
1294
1514
  }
1295
1515
  let targetStoryId = maps.storyIds.get(Number(sourceStory.id));
1296
1516
  if (!targetStoryId) {
1297
- targetStoryId = await createOrMatchReplacementShell({
1298
- node,
1299
- sourceStory,
1300
- parentId,
1301
- });
1517
+ try {
1518
+ targetStoryId = await createOrMatchReplacementShell({
1519
+ node,
1520
+ sourceStory,
1521
+ parentId,
1522
+ });
1523
+ }
1524
+ catch (error) {
1525
+ const message = error instanceof Error ? error.message : String(error);
1526
+ Logger.error(message);
1527
+ failures.push({
1528
+ sourceId: Number(sourceStory.id),
1529
+ fullSlug: String(sourceStory.full_slug ?? sourceStory.slug ?? ""),
1530
+ stage: "create-shell",
1531
+ message,
1532
+ });
1533
+ // No target shell means children cannot be parented; skip
1534
+ // this branch but keep copying the rest of the tree.
1535
+ continue;
1536
+ }
1302
1537
  }
1303
1538
  const writeStory = async (id) => {
1304
1539
  const current = buildRewrittenStoryPayload({
@@ -1328,6 +1563,7 @@ const rewriteCopiedStoryContents = async ({ tree, realParentId, sourceStoryById,
1328
1563
  if (!publishedUpdateResult?.ok) {
1329
1564
  return {
1330
1565
  result: publishedUpdateResult,
1566
+ content: publishedLayer.payload?.content,
1331
1567
  rewrittenReferences: current.rewrittenReferences +
1332
1568
  publishedLayer.rewrittenReferences,
1333
1569
  };
@@ -1341,6 +1577,7 @@ const rewriteCopiedStoryContents = async ({ tree, realParentId, sourceStoryById,
1341
1577
  if (!publishResult?.ok) {
1342
1578
  return {
1343
1579
  result: publishResult,
1580
+ content: publishedLayer.payload?.content,
1344
1581
  rewrittenReferences: current.rewrittenReferences +
1345
1582
  publishedLayer.rewrittenReferences,
1346
1583
  };
@@ -1354,6 +1591,7 @@ const rewriteCopiedStoryContents = async ({ tree, realParentId, sourceStoryById,
1354
1591
  });
1355
1592
  return {
1356
1593
  result: restoreDraftResult,
1594
+ content: current.payload?.content,
1357
1595
  rewrittenReferences: current.rewrittenReferences +
1358
1596
  publishedLayer.rewrittenReferences,
1359
1597
  };
@@ -1380,37 +1618,64 @@ const rewriteCopiedStoryContents = async ({ tree, realParentId, sourceStoryById,
1380
1618
  if (!publishResult?.ok) {
1381
1619
  return {
1382
1620
  result: publishResult,
1621
+ content: current.payload?.content,
1383
1622
  rewrittenReferences: current.rewrittenReferences,
1384
1623
  };
1385
1624
  }
1386
1625
  }
1387
1626
  return {
1388
1627
  result,
1628
+ content: current.payload?.content,
1389
1629
  rewrittenReferences: current.rewrittenReferences,
1390
1630
  };
1391
1631
  };
1392
- let update = await writeStory(Number(targetStoryId));
1393
- if (isStoryUpdateNotFound(update.result)) {
1394
- Logger.warning(`Ignoring stale story manifest mapping for '${sourceStory.full_slug ?? sourceStory.slug}' because target story '${targetStoryId}' could not be updated in space '${targetSpace}'.`);
1395
- maps.storyIds.delete(Number(sourceStory.id));
1396
- maps.storyUuids.delete(String(sourceStory.uuid));
1397
- targetStoryId = await createOrMatchReplacementShell({
1398
- node,
1632
+ // A single broken story (e.g. a component the target space schema
1633
+ // rejects) must not abort the whole run. Record the failure, keep
1634
+ // its already-created shell as the parent for descendants, and move
1635
+ // on to the next story.
1636
+ let targetInvalidated = false;
1637
+ try {
1638
+ let update = await writeStory(Number(targetStoryId));
1639
+ if (isStoryUpdateNotFound(update.result)) {
1640
+ Logger.warning(`Ignoring stale story manifest mapping for '${sourceStory.full_slug ?? sourceStory.slug}' because target story '${targetStoryId}' could not be updated in space '${targetSpace}'.`);
1641
+ maps.storyIds.delete(Number(sourceStory.id));
1642
+ maps.storyUuids.delete(String(sourceStory.uuid));
1643
+ targetInvalidated = true;
1644
+ targetStoryId = await createOrMatchReplacementShell({
1645
+ node,
1646
+ sourceStory,
1647
+ parentId,
1648
+ staleTargetId: Number(targetStoryId),
1649
+ });
1650
+ targetInvalidated = false;
1651
+ update = await writeStory(Number(targetStoryId));
1652
+ }
1653
+ assertStoryUpdateSucceeded({
1654
+ result: update.result,
1399
1655
  sourceStory,
1400
- parentId,
1401
- staleTargetId: Number(targetStoryId),
1656
+ targetStoryId: Number(targetStoryId),
1657
+ targetSpace,
1658
+ content: update.content,
1402
1659
  });
1403
- update = await writeStory(Number(targetStoryId));
1660
+ updatedStories += 1;
1661
+ rewrittenReferences += update.rewrittenReferences;
1662
+ }
1663
+ catch (error) {
1664
+ const message = error instanceof Error ? error.message : String(error);
1665
+ Logger.error(message);
1666
+ failures.push({
1667
+ sourceId: Number(sourceStory.id),
1668
+ targetId: targetStoryId ? Number(targetStoryId) : undefined,
1669
+ fullSlug: String(sourceStory.full_slug ?? sourceStory.slug ?? ""),
1670
+ stage: "update",
1671
+ message,
1672
+ });
1673
+ }
1674
+ // Only recurse into children when we still have a usable target
1675
+ // shell to parent them under.
1676
+ if (!targetInvalidated && targetStoryId) {
1677
+ await walk(node.children ?? [], Number(targetStoryId));
1404
1678
  }
1405
- assertStoryUpdateSucceeded({
1406
- result: update.result,
1407
- sourceStory,
1408
- targetStoryId: Number(targetStoryId),
1409
- targetSpace,
1410
- });
1411
- updatedStories += 1;
1412
- rewrittenReferences += update.rewrittenReferences;
1413
- await walk(node.children ?? [], Number(targetStoryId));
1414
1679
  }
1415
1680
  };
1416
1681
  await walk(tree, realParentId);
@@ -1419,6 +1684,21 @@ const rewriteCopiedStoryContents = async ({ tree, realParentId, sourceStoryById,
1419
1684
  if (updatedStories > 0) {
1420
1685
  Logger.success(`Updated ${updatedStories} copied story/story shell(s); rewrote ${rewrittenReferences} reference(s).`);
1421
1686
  }
1687
+ if (failures.length > 0) {
1688
+ Logger.error(`${failures.length} story/story shell update(s) failed; the rest of the copy still completed. Failed stories:`);
1689
+ for (const failure of failures) {
1690
+ const targetLabel = failure.targetId
1691
+ ? `, target id ${failure.targetId}`
1692
+ : "";
1693
+ Logger.error(` - ${failure.fullSlug || "<unknown>"} (source id ${failure.sourceId}${targetLabel}) [${failure.stage}]`);
1694
+ }
1695
+ // Every story was still attempted (no early abort), but surface the
1696
+ // failures so apply mode exits non-zero instead of reporting success.
1697
+ throw new Error(`Copy finished but ${failures.length} story/story shell update(s) failed:\n${failures
1698
+ .map((failure) => failure.message)
1699
+ .join("\n")}`);
1700
+ }
1701
+ return { updatedStories, rewrittenReferences, failures };
1422
1702
  };
1423
1703
  const createStoriesAndWriteManifests = async ({ tree, realParentId, sourceStoryById, targetSlugBySourceSlug, sourceSpace, targetSpace, manifestRoot, }) => {
1424
1704
  const manifestPaths = getDefaultCopyManifestPaths({
@@ -1818,6 +2098,46 @@ const logDryRunCopyPlan = async ({ report }) => {
1818
2098
  Logger.warning(`[dry-run] asset_ref ${reference.status.padEnd(10)} ${reference.filename ?? reference.assetId ?? "<unknown>"} at ${reference.sourceStoryFullSlug ?? reference.path}`);
1819
2099
  }
1820
2100
  }
2101
+ const compatibility = report.componentCompatibility;
2102
+ if (compatibility && !compatibility.checked) {
2103
+ Logger.warning("[dry-run] Component compatibility check skipped (no target components resolved).");
2104
+ }
2105
+ else if (compatibility && compatibility.findings.length === 0) {
2106
+ Logger.success("[dry-run] All source components are accepted by the target space schema.");
2107
+ }
2108
+ else if (compatibility) {
2109
+ Logger.error(`[dry-run] ${compatibility.findings.length} component compatibility issue(s) would make story updates fail with a 422:`);
2110
+ // Group by component + reason so the output stays readable when the
2111
+ // same component is used across many stories.
2112
+ const grouped = new Map();
2113
+ for (const finding of compatibility.findings) {
2114
+ const key = `${finding.reason}::${finding.component}`;
2115
+ const bucket = grouped.get(key) ?? {
2116
+ reason: finding.reason,
2117
+ component: finding.component,
2118
+ findings: [],
2119
+ };
2120
+ bucket.findings.push(finding);
2121
+ grouped.set(key, bucket);
2122
+ }
2123
+ for (const bucket of grouped.values()) {
2124
+ const reasonLabel = bucket.reason === "missing_in_target"
2125
+ ? "missing from target space schema"
2126
+ : "not allowed in the target field";
2127
+ const example = bucket.findings[0];
2128
+ const contextParts = [
2129
+ example?.field ? `field '${example.field}'` : undefined,
2130
+ example?.parentComponent
2131
+ ? `component '${example.parentComponent}'`
2132
+ : undefined,
2133
+ example?.uid ? `_uid ${example.uid}` : undefined,
2134
+ ].filter(Boolean);
2135
+ const exampleLabel = example
2136
+ ? ` e.g. '${example.sourceFullSlug}' at ${example.path}${contextParts.length ? ` (${contextParts.join(", ")})` : ""}`
2137
+ : "";
2138
+ Logger.error(`[dry-run] ${bucket.component}: ${reasonLabel} — ${bucket.findings.length} occurrence(s).${exampleLabel}`);
2139
+ }
2140
+ }
1821
2141
  report.warnings.forEach((warning) => Logger.warning(`[dry-run] ${warning.message}`));
1822
2142
  };
1823
2143
  const logDryRunCopyAssetsPlan = async ({ report, }) => {
@@ -1937,6 +2257,14 @@ export const copyCommand = async (props) => {
1937
2257
  }
1938
2258
  if (dryRun) {
1939
2259
  const conflicts = await findTargetConflicts(plan, targetSpace);
2260
+ Logger.warning("Checking source components against the target space schema.");
2261
+ const componentValidator = await buildTargetComponentValidator(targetSpace);
2262
+ const componentCompatibility = componentValidator.canValidate
2263
+ ? summarizeComponentCompatibility(true, sourceStories.flatMap((item) => componentValidator.validateStory(item?.story)))
2264
+ : summarizeComponentCompatibility(false, []);
2265
+ if (!componentCompatibility.checked) {
2266
+ Logger.warning(`Skipped component compatibility check because no components were returned for target space '${targetSpace}'.`);
2267
+ }
1940
2268
  Logger.warning("Building dry-run copy report.");
1941
2269
  const report = buildCopyDryRunReport({
1942
2270
  sourceSpace,
@@ -1948,6 +2276,7 @@ export const copyCommand = async (props) => {
1948
2276
  plan,
1949
2277
  conflicts,
1950
2278
  graph: dryRunGraph,
2279
+ componentCompatibility,
1951
2280
  outputPath,
1952
2281
  });
1953
2282
  await logDryRunCopyPlan({ report });
@@ -39,6 +39,7 @@ export declare const isDirectoryExists: (path: string) => boolean;
39
39
  export declare const createDir: (dirPath: string) => Promise<void>;
40
40
  export declare const createJsonFile: (content: string, pathWithFilename: string) => Promise<void>;
41
41
  export declare const writeJsonArrayStreamed: (items: any[], pathWithFilename: string) => Promise<void>;
42
+ export declare const readJsonArrayStreamed: (pathToFile: string) => Promise<any[]>;
42
43
  export declare const createJSAllComponentsFile: (content: string, pathWithFilename: string, timestamp?: boolean) => Promise<void>;
43
44
  export declare const copyFolder: (src: string, dest: string) => Promise<unknown>;
44
45
  export declare const copyFile: (src: string, dest: string) => Promise<void>;
@@ -153,6 +153,136 @@ export const writeJsonArrayStreamed = (items, pathWithFilename) => {
153
153
  });
154
154
  });
155
155
  };
156
+ /*
157
+ * Streams a top-level JSON array from disk, parsing one element at a time.
158
+ * The counterpart to writeJsonArrayStreamed: large migration artifacts are
159
+ * written without ever building the whole document as a single string, so they
160
+ * must also be READ without `fs.readFile(...).toString()`, which throws
161
+ * ERR_STRING_TOO_LONG once the file passes V8's ~512 MB max string length.
162
+ *
163
+ * Only the array document is streamed; each individual element is parsed with
164
+ * JSON.parse (elements are small). Throws if the file is not a JSON array.
165
+ */
166
+ export const readJsonArrayStreamed = (pathToFile) => {
167
+ return new Promise((resolve, reject) => {
168
+ const stream = fs.createReadStream(resolveFromCwd(pathToFile), {
169
+ encoding: "utf8",
170
+ });
171
+ const items = [];
172
+ let started = false; // seen the opening `[`
173
+ let done = false; // seen the matching top-level `]`
174
+ let buffer = ""; // current element text
175
+ let depth = 0; // nesting depth inside the current element
176
+ let inString = false;
177
+ let escaped = false;
178
+ let settled = false;
179
+ const fail = (err) => {
180
+ if (settled)
181
+ return;
182
+ settled = true;
183
+ stream.destroy();
184
+ reject(err);
185
+ };
186
+ const flushElement = () => {
187
+ const text = buffer.trim();
188
+ buffer = "";
189
+ if (text.length === 0) {
190
+ return;
191
+ }
192
+ items.push(JSON.parse(text));
193
+ };
194
+ stream.on("data", (rawChunk) => {
195
+ if (done)
196
+ return;
197
+ // The stream is opened with utf8 encoding, so chunks arrive as
198
+ // strings (decoded across multi-byte boundaries). The Buffer arm is
199
+ // only here to satisfy the Node type signature.
200
+ const chunk = typeof rawChunk === "string"
201
+ ? rawChunk
202
+ : rawChunk.toString("utf8");
203
+ for (let i = 0; i < chunk.length; i++) {
204
+ const ch = chunk[i];
205
+ if (!started) {
206
+ if (ch === "[") {
207
+ started = true;
208
+ }
209
+ else if (ch.trim() !== "") {
210
+ fail(new Error(`Expected a JSON array in ${pathToFile}, found "${ch}".`));
211
+ return;
212
+ }
213
+ continue;
214
+ }
215
+ if (inString) {
216
+ buffer += ch;
217
+ if (escaped) {
218
+ escaped = false;
219
+ }
220
+ else if (ch === "\\") {
221
+ escaped = true;
222
+ }
223
+ else if (ch === '"') {
224
+ inString = false;
225
+ }
226
+ continue;
227
+ }
228
+ if (ch === '"') {
229
+ inString = true;
230
+ buffer += ch;
231
+ continue;
232
+ }
233
+ if (ch === "{" || ch === "[") {
234
+ depth++;
235
+ buffer += ch;
236
+ continue;
237
+ }
238
+ if (ch === "}") {
239
+ depth--;
240
+ buffer += ch;
241
+ continue;
242
+ }
243
+ if (ch === "]") {
244
+ if (depth === 0) {
245
+ // closing the top-level array
246
+ try {
247
+ flushElement();
248
+ }
249
+ catch (error) {
250
+ fail(error);
251
+ return;
252
+ }
253
+ done = true;
254
+ break;
255
+ }
256
+ depth--;
257
+ buffer += ch;
258
+ continue;
259
+ }
260
+ if (ch === "," && depth === 0) {
261
+ try {
262
+ flushElement();
263
+ }
264
+ catch (error) {
265
+ fail(error);
266
+ return;
267
+ }
268
+ continue;
269
+ }
270
+ buffer += ch;
271
+ }
272
+ });
273
+ stream.on("error", fail);
274
+ stream.on("end", () => {
275
+ if (settled)
276
+ return;
277
+ if (!started) {
278
+ fail(new Error(`Empty or non-JSON file: ${pathToFile}`));
279
+ return;
280
+ }
281
+ settled = true;
282
+ resolve(items);
283
+ });
284
+ });
285
+ };
156
286
  export const createJSAllComponentsFile = async (content, pathWithFilename, timestamp = false) => {
157
287
  const datestamp = new Date();
158
288
  const finalContent = `/*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sb-mig",
3
- "version": "6.4.0-beta.1",
3
+ "version": "6.4.1-beta.1",
4
4
  "description": "CLI to rule the world. (and handle stuff related to Storyblok CMS)",
5
5
  "author": "Marcin Krawczyk <marckraw@icloud.com>",
6
6
  "license": "MIT",