sb-mig 6.4.0 → 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 =
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1656
|
+
targetStoryId: Number(targetStoryId),
|
|
1657
|
+
targetSpace,
|
|
1658
|
+
content: update.content,
|
|
1402
1659
|
});
|
|
1403
|
-
|
|
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 });
|
package/dist/cli/index.js
CHANGED
|
File without changes
|
package/dist/utils/files.d.ts
CHANGED
|
@@ -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>;
|
package/dist/utils/files.js
CHANGED
|
@@ -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 = `/*
|