Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,5 @@
"name": "Blog",
"slug": "blog-index",
"full_slug": "blog-index",
"is_folder": true,
"content": {
"component": "page",
"headline": "Blog",
"hero_image": {
"id": 1,
"fieldtype": "asset",
"filename": "https://placeholder.example.com/hero.png",
"alt": "Blog hero"
},
"seo_description": "Our blog"
}
"is_folder": true
}
53 changes: 53 additions & 0 deletions packages/cli/src/commands/schema/affected/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Schema Affected Command

The `schema affected` command reports which stories a pending schema change affects and which would
break. It is a read-only dry run of `schema push`: it diffs your local code-first schema against the
remote space, then checks every story that uses a changed component for breakage.

## Basic Usage

Point the command at your schema entry file and target space:

```bash
storyblok schema affected ./schema/index.ts --space YOUR_SPACE_ID
```

The summary lists each impacted component with the number of stories affected, how many would break,
and the field-level change that causes it:

```
hero (changed): 3 stories affected, 3 would break
- badge (required_added): present in 0 stories, 3 would break

Totals: 3 stories affected across 1 component; 3 would break.
```

Gate a CI pipeline by failing the run when any story would break:

```bash
storyblok schema affected ./schema/index.ts --space YOUR_SPACE_ID --fail-on-break
```

## Options

| Option | Description | Default |
| --------------------- | ------------------------------------------------------------------------------------------------ | ------------ |
| `-s, --space <space>` | (Required) The ID of the space to diff against | - |
| `-p, --path <path>` | Base path where local stories are read from with `--local` (stories live under `<path>/stories`) | `.storyblok` |
| `--local` | Analyze locally pulled stories instead of fetching from the space | `false` |
| `--include-deleted` | Treat remote-only components as deleted, mirroring `schema push --delete` | `false` |
| `--fail-on-break` | Exit with a non-zero code when any story would break, for CI gating | `false` |

## Notes

- The analysis covers field-structural changes (field add, remove, type change, newly required
fields) and component deletion (`--include-deleted`). It does not flag nested allow-list or
reference constraint changes, because Storyblok tolerates orphaned nested bloks, so existing
content is not broken by those changes.
- Breakage is diffed against both the old and new schema, so only errors the change introduces are
counted. Pre-existing invalid content is not misattributed.
- By default the command fetches only the stories that use an impacted component (as a nested blok
or as their root content type) directly from the space. Use `--local` to analyze already-pulled
story JSON instead. Run `storyblok stories pull --space YOUR_SPACE_ID` first.
- The full per-story and per-field detail is written to the standard command report file when
reporting is enabled (`--report-enabled`).
291 changes: 291 additions & 0 deletions packages/cli/src/commands/schema/affected/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import { describe, expect, it } from "vitest";

import type { Component, Story } from "../../../types";
import type { DiffResult } from "../types";
import type { ComponentBreakingChanges } from "../migrations/types";
import { toSchemaLike } from "../to-schema-like";
import {
aggregate,
analyzeStory,
computeImpactedComponents,
createAnalyzeContext,
type ImpactedMap,
} from "./actions";

function makeComponent(name: string, schema: Record<string, Record<string, unknown>>): Component {
return {
id: 1,
name,
created_at: "",
updated_at: "",
is_root: false,
is_nestable: true,
schema,
} as unknown as Component;
}

function makeStory(overrides: Partial<Story> & { content: unknown }): Story {
return { id: 1, uuid: "u", name: "Story", full_slug: "story", ...overrides } as unknown as Story;
}

describe("computeImpactedComponents", () => {
const emptyDiff: DiffResult = { diffs: [], creates: 0, updates: 0, unchanged: 0, stale: 0 };

it("should mark components with breaking changes as update", () => {
const breaking: ComponentBreakingChanges[] = [
{ componentName: "hero", changes: [{ kind: "removed", field: "subtitle" }] },
];
const impacted = computeImpactedComponents(emptyDiff, breaking);

expect(impacted.get("hero")).toMatchObject({ action: "update" });
expect(impacted.get("hero")?.fields[0]).toMatchObject({
field: "subtitle",
contentKey: "subtitle",
kind: "removed",
});
});

it("should mark stale components as removed only with withDelete", () => {
const diff: DiffResult = {
diffs: [
{
type: "component",
name: "teaser",
action: "stale",
diff: null,
local: null,
remote: null,
},
],
creates: 0,
updates: 0,
unchanged: 0,
stale: 1,
};

expect(computeImpactedComponents(diff, []).has("teaser")).toBe(false);
expect(computeImpactedComponents(diff, [], { withDelete: true }).get("teaser")).toMatchObject({
action: "removed",
});
});

it("should map a rename content key to the old field name", () => {
const breaking: ComponentBreakingChanges[] = [
{
componentName: "hero",
changes: [{ kind: "rename", field: "headline", oldField: "title" }],
},
];
const impacted = computeImpactedComponents(emptyDiff, breaking);

expect(impacted.get("hero")?.fields[0]).toMatchObject({
field: "headline",
contentKey: "title",
kind: "rename",
});
});
});

describe("analyzeStory", () => {
it("should flag a removed field as a warning, not broken", () => {
const oldSchema = toSchemaLike([
makeComponent("hero", { title: { type: "text" }, subtitle: { type: "text" } }),
]);
const newSchema = toSchemaLike([makeComponent("hero", { title: { type: "text" } })]);
const impacted: ImpactedMap = new Map([
[
"hero",
{
component: "hero",
action: "update",
fields: [{ field: "subtitle", contentKey: "subtitle", kind: "removed" }],
},
],
]);
const story = makeStory({
content: { _uid: "r", component: "hero", title: "Hi", subtitle: "gone" },
});

const result = analyzeStory(story, createAnalyzeContext(impacted, oldSchema, newSchema));

expect(result?.components).toEqual(["hero"]);
expect(result?.broken).toBe(false);
expect(result?.usedFields).toEqual([{ component: "hero", field: "subtitle" }]);
expect(result?.issues.some((i) => i.code === "unknown_field" && i.severity === "warning")).toBe(
true,
);
});

it("should flag a newly required field as broken", () => {
const oldSchema = toSchemaLike([makeComponent("hero", { title: { type: "text" } })]);
const newSchema = toSchemaLike([
makeComponent("hero", { title: { type: "text", required: true } }),
]);
const impacted: ImpactedMap = new Map([
[
"hero",
{
component: "hero",
action: "update",
fields: [{ field: "title", contentKey: "title", kind: "required_added" }],
},
],
]);
const story = makeStory({ content: { _uid: "r", component: "hero" } });

const result = analyzeStory(story, createAnalyzeContext(impacted, oldSchema, newSchema));

expect(result?.broken).toBe(true);
expect(
result?.issues.some(
(i) => i.component === "hero" && i.field === "title" && i.severity === "error",
),
).toBe(true);
});

it("should not count pre-existing errors as change-induced breakage", () => {
// `title` is required in both schemas; the story already violates it, so the
// analyzed change (adding required `subtitle`) must not blame `title`.
const oldSchema = toSchemaLike([
makeComponent("hero", { title: { type: "text", required: true } }),
]);
const newSchema = toSchemaLike([
makeComponent("hero", {
title: { type: "text", required: true },
subtitle: { type: "text" },
}),
]);
const impacted: ImpactedMap = new Map([
[
"hero",
{
component: "hero",
action: "update",
fields: [{ field: "subtitle", contentKey: "subtitle", kind: "required_added" }],
},
],
]);
const story = makeStory({ content: { _uid: "r", component: "hero" } });

const result = analyzeStory(story, createAnalyzeContext(impacted, oldSchema, newSchema));

expect(result?.broken).toBe(false);
expect(result?.issues).toEqual([]);
});

it("should attribute a rename error to the renamed field", () => {
const oldSchema = toSchemaLike([makeComponent("hero", { title: { type: "text" } })]);
const newSchema = toSchemaLike([
makeComponent("hero", { headline: { type: "text", required: true } }),
]);
const impacted: ImpactedMap = new Map([
[
"hero",
{
component: "hero",
action: "update",
fields: [{ field: "headline", contentKey: "title", kind: "rename" }],
},
],
]);
const story = makeStory({ content: { _uid: "r", component: "hero", title: "Hi" } });

const result = analyzeStory(story, createAnalyzeContext(impacted, oldSchema, newSchema));

expect(result?.broken).toBe(true);
expect(result?.usedFields).toEqual([{ component: "hero", field: "headline" }]);
expect(
result?.issues.some((i) => i.field === "headline" && i.code === "missing_required_field"),
).toBe(true);
});

it("should flag stories using a removed component as broken", () => {
const oldSchema = toSchemaLike([
makeComponent("page", { body: { type: "bloks" } }),
makeComponent("teaser", {}),
]);
const newSchema = toSchemaLike([makeComponent("page", { body: { type: "bloks" } })]);
const impacted: ImpactedMap = new Map([
["teaser", { component: "teaser", action: "removed", fields: [] }],
]);
const story = makeStory({
content: { _uid: "r", component: "page", body: [{ _uid: "a", component: "teaser" }] },
});

const result = analyzeStory(story, createAnalyzeContext(impacted, oldSchema, newSchema));

expect(result?.broken).toBe(true);
expect(
result?.issues.some((i) => i.component === "teaser" && i.code === "component_removed"),
).toBe(true);
});

it("should return null for stories that do not use any impacted component", () => {
const schema = toSchemaLike([makeComponent("hero", { title: { type: "text" } })]);
const impacted: ImpactedMap = new Map([
[
"hero",
{
component: "hero",
action: "update",
fields: [{ field: "title", contentKey: "title", kind: "type_changed" }],
},
],
]);
const story = makeStory({ content: { _uid: "r", component: "other" } });

expect(analyzeStory(story, createAnalyzeContext(impacted, schema, schema))).toBeNull();
});
});

describe("aggregate", () => {
it("should total used and broken stories per component and field", () => {
const impacted: ImpactedMap = new Map([
[
"hero",
{
component: "hero",
action: "update",
fields: [{ field: "title", contentKey: "title", kind: "required_added" }],
},
],
]);
const stories = [
{
id: 1,
uuid: "a",
name: "A",
full_slug: "a",
components: ["hero"],
usedFields: [{ component: "hero", field: "title" }],
broken: true,
issues: [
{
component: "hero",
field: "title",
severity: "error" as const,
code: "missing_required_field",
message: "",
},
],
},
{
id: 2,
uuid: "b",
name: "B",
full_slug: "b",
components: ["hero"],
usedFields: [],
broken: false,
issues: [],
},
];

const report = aggregate("12345", impacted, stories);

expect(report.totals).toEqual({ usedStories: 2, brokenStories: 1, brokenComponents: 1 });
const hero = report.components[0];
expect(hero).toMatchObject({ component: "hero", usedStories: 2, brokenStories: 1 });
expect(hero.fields[0]).toMatchObject({ field: "title", used: 1, broken: 1 });
});
});
Loading
Loading