diff --git a/packages/cli/src/commands/reports/list/index.test.ts b/packages/cli/src/commands/reports/list/index.test.ts index 15773d2af..a9745f47a 100644 --- a/packages/cli/src/commands/reports/list/index.test.ts +++ b/packages/cli/src/commands/reports/list/index.test.ts @@ -13,9 +13,9 @@ const REPORTS_FILE_DIR = resolveCommandPath("reports", "12345"); const preconditions = { hasReportFiles() { vol.fromJSON({ - [join(REPORTS_FILE_DIR, "storyblok-migrations-run-1234567890.jsonl")]: "foo", - [join(REPORTS_FILE_DIR, "storyblok-migrations-run-1234567891.jsonl")]: "foo", - [join(REPORTS_FILE_DIR, "storyblok-components-push-1234567892.jsonl")]: "foo", + [join(REPORTS_FILE_DIR, "storyblok-migrations-run-1234567890.json")]: "foo", + [join(REPORTS_FILE_DIR, "storyblok-migrations-run-1234567891.json")]: "foo", + [join(REPORTS_FILE_DIR, "storyblok-components-push-1234567892.json")]: "foo", }); }, hasNoReportFiles() { @@ -26,6 +26,11 @@ const preconditions = { "reports/12345/.gitkeep": "", }); }, + hasSpacelessReportFiles() { + vol.fromJSON({ + [join(resolveCommandPath("reports"), "storyblok-schema-diff-1234567890.json")]: "foo", + }); + }, }; describe("reports list command", () => { @@ -43,13 +48,13 @@ describe("reports list command", () => { expect.stringContaining('Found 3 report files for space "12345":'), ); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("storyblok-components-push-1234567892.jsonl"), + expect.stringContaining("storyblok-components-push-1234567892.json"), ); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("storyblok-migrations-run-1234567890.jsonl"), + expect.stringContaining("storyblok-migrations-run-1234567890.json"), ); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("storyblok-migrations-run-1234567891.jsonl"), + expect.stringContaining("storyblok-migrations-run-1234567891.json"), ); }); @@ -72,4 +77,25 @@ describe("reports list command", () => { expect.stringContaining('No reports found for space "12345"'), ); }); + + it('should list space-less reports without an "undefined" space label', async () => { + preconditions.hasSpacelessReportFiles(); + + await reportsCommand.parseAsync(["node", "test", "list"]); + + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Found 1 report file:")); + expect(console.error).not.toHaveBeenCalledWith(expect.stringContaining("undefined")); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("storyblok-schema-diff-1234567890.json"), + ); + }); + + it('should not say space "undefined" when no reports and no space given', async () => { + preconditions.hasNoReportFiles(); + + await reportsCommand.parseAsync(["node", "test", "list"]); + + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("No reports found.")); + expect(console.error).not.toHaveBeenCalledWith(expect.stringContaining("undefined")); + }); }); diff --git a/packages/cli/src/commands/reports/list/index.ts b/packages/cli/src/commands/reports/list/index.ts index 5dcc2afb4..b653a46cf 100644 --- a/packages/cli/src/commands/reports/list/index.ts +++ b/packages/cli/src/commands/reports/list/index.ts @@ -14,15 +14,16 @@ listCmd.action(async (_options: unknown, command: Command) => { const { space, path } = command.optsWithGlobals(); const ui = getUI(); const reportsPath = resolveCommandPath(directories.reports, space, path); - const reportFiles = Reporter.listReportFiles(reportsPath, ".jsonl"); + const reportFiles = Reporter.listReportFiles(reportsPath); + // Reports from commands without a space (e.g. file-to-file `schema diff`) + // live in the base reports directory, so only mention a space when given. + const scope = space ? ` for space "${space}"` : ""; if (reportFiles.length === 0) { - ui.info(`No reports found for space "${space}".`); + ui.info(`No reports found${scope}.`); return; } - ui.info( - `Found ${reportFiles.length} report file${reportFiles.length === 1 ? "" : "s"} for space "${space}":`, - ); + ui.info(`Found ${reportFiles.length} report file${reportFiles.length === 1 ? "" : "s"}${scope}:`); ui.list(reportFiles); }); diff --git a/packages/cli/src/commands/reports/prune/index.test.ts b/packages/cli/src/commands/reports/prune/index.test.ts index c9f3bda22..29dd671e2 100644 --- a/packages/cli/src/commands/reports/prune/index.test.ts +++ b/packages/cli/src/commands/reports/prune/index.test.ts @@ -14,9 +14,9 @@ const REPORTS_FILE_DIR = resolveCommandPath("reports", "12345"); const preconditions = { hasReportFiles() { vol.fromJSON({ - [join(REPORTS_FILE_DIR, "storyblok-migrations-run-1234567890.jsonl")]: "foo", - [join(REPORTS_FILE_DIR, "storyblok-migrations-run-1234567891.jsonl")]: "foo", - [join(REPORTS_FILE_DIR, "storyblok-components-push-1234567892.jsonl")]: "foo", + [join(REPORTS_FILE_DIR, "storyblok-migrations-run-1234567890.json")]: "foo", + [join(REPORTS_FILE_DIR, "storyblok-migrations-run-1234567891.json")]: "foo", + [join(REPORTS_FILE_DIR, "storyblok-components-push-1234567892.json")]: "foo", }); }, }; @@ -35,7 +35,7 @@ describe("reports prune command", () => { await reportsCommand.parseAsync(["node", "test", "prune", "--space", "12345"]); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Deleted 3 report files")); - const remainingFiles = Object.keys(vol.toJSON()).filter((path) => path.includes(".jsonl")); + const remainingFiles = Object.keys(vol.toJSON()).filter((path) => path.includes(".json")); expect(remainingFiles).toHaveLength(0); }); @@ -45,7 +45,7 @@ describe("reports prune command", () => { await reportsCommand.parseAsync(["node", "test", "prune", "--space", "12345", "--keep", "2"]); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Deleted 1 report file")); - const remainingFiles = Object.keys(vol.toJSON()).filter((path) => path.includes(".jsonl")); + const remainingFiles = Object.keys(vol.toJSON()).filter((path) => path.includes(".json")); expect(remainingFiles).toHaveLength(2); }); @@ -55,7 +55,7 @@ describe("reports prune command", () => { await reportsCommand.parseAsync(["node", "test", "prune", "--space", "12345", "--keep", "3"]); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Deleted 0 report files")); - const remainingFiles = Object.keys(vol.toJSON()).filter((path) => path.includes(".jsonl")); + const remainingFiles = Object.keys(vol.toJSON()).filter((path) => path.includes(".json")); expect(remainingFiles).toHaveLength(3); }); @@ -65,7 +65,7 @@ describe("reports prune command", () => { await reportsCommand.parseAsync(["node", "test", "prune", "--space", "12345", "--keep", "10"]); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Deleted 0 report files")); - const remainingFiles = Object.keys(vol.toJSON()).filter((path) => path.includes(".jsonl")); + const remainingFiles = Object.keys(vol.toJSON()).filter((path) => path.includes(".json")); expect(remainingFiles).toHaveLength(3); }); }); diff --git a/packages/cli/src/commands/reports/prune/index.ts b/packages/cli/src/commands/reports/prune/index.ts index 596788140..90dc17e19 100644 --- a/packages/cli/src/commands/reports/prune/index.ts +++ b/packages/cli/src/commands/reports/prune/index.ts @@ -20,7 +20,7 @@ pruneCmd.action(async (options: { keep: number }, command: Command) => { const { space, path } = command.optsWithGlobals(); const ui = getUI(); const reportsPath = resolveCommandPath(directories.reports, space, path); - const deletedFilesCount = Reporter.pruneReportFiles(reportsPath, options.keep, ".jsonl"); + const deletedFilesCount = Reporter.pruneReportFiles(reportsPath, options.keep); ui.info(`Deleted ${deletedFilesCount} report file${deletedFilesCount === 1 ? "" : "s"}`); }); diff --git a/packages/cli/src/commands/schema/actions.ts b/packages/cli/src/commands/schema/actions.ts index 6ee11a9e7..7746605db 100644 --- a/packages/cli/src/commands/schema/actions.ts +++ b/packages/cli/src/commands/schema/actions.ts @@ -1,6 +1,39 @@ -import type { RemoteSchemaData } from "./types"; +import type { LocalFolder, NormalizedSchema, RemoteSchemaData, SchemaData } from "./types"; import { getMapiClient } from "../../api"; import { fetchAllPages } from "../../utils"; +import { buildGroupPathByUuid } from "./folders"; + +/** + * Reduces remote state to the common {@link NormalizedSchema} shape. Remote + * component groups are resolved into slug-path identity space (via their uuid + * parent chain) so folders diff against local folders in the same terms. + */ +export function remoteToNormalized(remote: RemoteSchemaData): NormalizedSchema { + const groupPathByUuid = buildGroupPathByUuid([...remote.componentFolders.values()]); + const folders = new Map(); + for (const folder of remote.componentFolders.values()) { + const segments = groupPathByUuid.get(folder.uuid); + if (!segments || segments.length === 0) { + continue; + } + const path = segments.join("/"); + folders.set(path, { + name: folder.name, + path, + parentPath: segments.length > 1 ? segments.slice(0, -1).join("/") : null, + }); + } + return { components: remote.components, datasources: remote.datasources, folders }; +} + +/** Reduces locally-loaded schema arrays to the common {@link NormalizedSchema} shape. */ +export function localToNormalized(local: SchemaData): NormalizedSchema { + return { + components: new Map(local.components.map((c) => [c.name, c])), + datasources: new Map(local.datasources.map((d) => [d.name, d])), + folders: new Map(local.folders.map((f) => [f.path, f])), + }; +} /** * Fetches remote components, component folders, and datasources from the MAPI. diff --git a/packages/cli/src/commands/schema/diff-schema.test.ts b/packages/cli/src/commands/schema/diff-schema.test.ts new file mode 100644 index 000000000..ce1bb23f4 --- /dev/null +++ b/packages/cli/src/commands/schema/diff-schema.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; + +import type { Component, Datasource } from "../../types"; +import type { LocalFolder, NormalizedSchema } from "./types"; +import { diffSchema } from "./diff-schema"; + +function makeComponent(name: string, schema: Record) { + return { id: 1, name, created_at: "", updated_at: "", schema } as unknown as Component; +} + +function makeDatasource(name: string, slug: string) { + return { id: 1, name, slug, created_at: "", updated_at: "" } as unknown as Datasource; +} + +/** Builds a {@link NormalizedSchema} from entity arrays. */ +function normalized( + components: Component[] = [], + datasources: Datasource[] = [], + folders: LocalFolder[] = [], +): NormalizedSchema { + return { + components: new Map(components.map((c) => [c.name, c])), + datasources: new Map(datasources.map((d) => [d.name, d])), + folders: new Map(folders.map((f) => [f.path, f])), + }; +} + +// `diffSchema(from, to)` describes how to get from base (`from`) to target (`to`). +// For push semantics, from = remote, to = local. +describe("diffSchema", () => { + it("should detect entities only in the target as create", () => { + const from = normalized(); + const to = normalized([makeComponent("page", { title: { type: "text", pos: 0 } })]); + + const result = diffSchema(from, to); + + expect(result.creates).toBe(1); + expect(result.diffs[0].action).toBe("create"); + expect(result.diffs[0].name).toBe("page"); + expect(result.diffs[0].after).toMatchObject({ name: "page" }); + }); + + it("should detect unchanged entities", () => { + const comp = makeComponent("page", { title: { type: "text", pos: 0 } }); + const from = normalized([{ ...comp, id: 99 } as Component]); + const to = normalized([comp]); + + const result = diffSchema(from, to); + + expect(result.unchanged).toBe(1); + expect(result.diffs[0].action).toBe("unchanged"); + }); + + it("should detect updated entities with field-level changes", () => { + const remoteComp = makeComponent("page", { title: { type: "text", pos: 0, max_length: 60 } }); + const localComp = makeComponent("page", { title: { type: "text", pos: 0, max_length: 70 } }); + + const result = diffSchema( + normalized([{ ...remoteComp, id: 99 } as Component]), + normalized([localComp]), + ); + + expect(result.updates).toBe(1); + expect(result.diffs[0].action).toBe("update"); + const titleChange = result.diffs[0].changes.find((c) => c.field === "title"); + expect(titleChange?.change).toBe("modified"); + expect(titleChange?.before).toMatchObject({ max_length: 60 }); + expect(titleChange?.after).toMatchObject({ max_length: 70 }); + }); + + it("should report an added schema field as an added change", () => { + const remoteComp = makeComponent("page", { title: { type: "text", pos: 0 } }); + const localComp = makeComponent("page", { + title: { type: "text", pos: 0 }, + subtitle: { type: "text", pos: 1 }, + }); + + const result = diffSchema( + normalized([{ ...remoteComp, id: 99 } as Component]), + normalized([localComp]), + ); + + const subtitle = result.diffs[0].changes.find((c) => c.field === "subtitle"); + expect(subtitle?.change).toBe("added"); + expect(subtitle?.after).toMatchObject({ type: "text" }); + }); + + it("should report a removed schema field as a removed change", () => { + const remoteComp = makeComponent("page", { + title: { type: "text", pos: 0 }, + subtitle: { type: "text", pos: 1 }, + }); + const localComp = makeComponent("page", { title: { type: "text", pos: 0 } }); + + const result = diffSchema( + normalized([{ ...remoteComp, id: 99 } as Component]), + normalized([localComp]), + ); + + const subtitle = result.diffs[0].changes.find((c) => c.field === "subtitle"); + expect(subtitle?.change).toBe("removed"); + expect(subtitle?.before).toMatchObject({ type: "text" }); + }); + + it("should detect entities only in the base as stale", () => { + const from = normalized([makeComponent("footer", {})]); + const to = normalized(); + + const result = diffSchema(from, to); + + expect(result.stale).toBe(1); + expect(result.diffs[0].action).toBe("stale"); + expect(result.diffs[0].name).toBe("footer"); + expect(result.diffs[0].before).toMatchObject({ name: "footer" }); + }); + + it("should not show a change for auto-populated defaults (e.g. internal_tag_ids)", () => { + const localComp = makeComponent("page", { title: { type: "text", pos: 0 } }); + const remoteComp = { + ...makeComponent("page", { title: { type: "text", pos: 0 } }), + internal_tag_ids: [], + } as Component; + + const result = diffSchema(normalized([remoteComp]), normalized([localComp])); + + expect(result.unchanged).toBe(1); + expect(result.updates).toBe(0); + }); + + it("should show a change when target explicitly sets internal_tag_ids differently", () => { + const localComp = { + ...makeComponent("page", { title: { type: "text", pos: 0 } }), + internal_tag_ids: [10], + } as Component; + const remoteComp = { + ...makeComponent("page", { title: { type: "text", pos: 0 } }), + internal_tag_ids: [], + } as Component; + + const result = diffSchema(normalized([remoteComp]), normalized([localComp])); + + expect(result.updates).toBe(1); + expect(result.diffs[0].changes.some((c) => c.field === "internal_tag_ids")).toBe(true); + }); + + it("should not show a change when base has an empty description and target does not set it", () => { + const localComp = makeComponent("test", { title: { type: "text", pos: 0 } }); + const remoteComp = { + ...makeComponent("test", { title: { type: "text", pos: 0 } }), + description: "", + } as Component; + + const result = diffSchema(normalized([remoteComp]), normalized([localComp])); + + expect(result.unchanged).toBe(1); + expect(result.updates).toBe(0); + }); + + it("should show a change when the target removes a base description", () => { + const localComp = makeComponent("test", { title: { type: "text", pos: 0 } }); + const remoteComp = { + ...makeComponent("test", { title: { type: "text", pos: 0 } }), + description: "A test block", + } as Component; + + const result = diffSchema(normalized([remoteComp]), normalized([localComp])); + + expect(result.updates).toBe(1); + expect(result.diffs[0].changes.some((c) => c.field === "description")).toBe(true); + }); + + it("should treat a datasource without dimensions as unchanged when base has empty dimensions", () => { + const from = normalized( + [], + [{ ...makeDatasource("Colors", "colors"), dimensions: [] } as unknown as Datasource], + ); + const to = normalized([], [makeDatasource("Colors", "colors")]); + + const result = diffSchema(from, to); + + expect(result.unchanged).toBe(1); + expect(result.updates).toBe(0); + }); + + it("should not diff component_group_uuid when the target does not opt into the escape hatch", () => { + const localComp = makeComponent("page", { title: { type: "text", pos: 0 } }); + const remoteComp = { + ...makeComponent("page", { title: { type: "text", pos: 0 } }), + component_group_uuid: "group-uuid", + } as Component; + + const result = diffSchema(normalized([remoteComp]), normalized([localComp]), { + compareGroupUuid: true, + }); + + expect(result.unchanged).toBe(1); + expect(result.updates).toBe(0); + }); + + it("should diff component_group_uuid when the target sets it (group escape hatch)", () => { + const localComp = { + ...makeComponent("page", { title: { type: "text", pos: 0 } }), + component_group_uuid: "new-group", + } as Component; + const remoteComp = { + ...makeComponent("page", { title: { type: "text", pos: 0 } }), + component_group_uuid: "old-group", + } as Component; + + const result = diffSchema(normalized([remoteComp]), normalized([localComp]), { + compareGroupUuid: true, + }); + + expect(result.updates).toBe(1); + expect(result.diffs[0].changes.some((c) => c.field === "component_group_uuid")).toBe(true); + }); + + it("should treat components differing only by component_group_uuid as unchanged for space-to-space diffs", () => { + // Group UUIDs are per-space identifiers; without opting in (the default, as + // used for space-to-space diffs) they must not surface as a change. + const spaceA = { + ...makeComponent("hero", { title: { type: "text", pos: 0 } }), + component_group_uuid: "group-a", + } as Component; + const spaceB = { + ...makeComponent("hero", { title: { type: "text", pos: 0 } }), + component_group_uuid: "group-b", + } as Component; + + const result = diffSchema(normalized([spaceA]), normalized([spaceB])); + + expect(result.updates).toBe(0); + expect(result.unchanged).toBe(1); + expect(result.diffs[0].changes.some((c) => c.field === "component_group_uuid")).toBe(false); + }); + + it("should handle all entity types together", () => { + const to = normalized([makeComponent("page", {})], [makeDatasource("Colors", "colors")]); + + const result = diffSchema(normalized(), to); + + expect(result.creates).toBe(2); + expect(result.diffs).toHaveLength(2); + }); +}); diff --git a/packages/cli/src/commands/schema/diff-schema.ts b/packages/cli/src/commands/schema/diff-schema.ts new file mode 100644 index 000000000..14b2fb490 --- /dev/null +++ b/packages/cli/src/commands/schema/diff-schema.ts @@ -0,0 +1,190 @@ +import type { Component, Datasource } from "../../types"; +import type { DiffResult, EntityDiff, FieldChange, LocalFolder, NormalizedSchema } from "./types"; +import { + applyDefaults, + COMPONENT_DEFAULTS, + DATASOURCE_DEFAULTS, + formatValue, + isRecord, +} from "./utils"; +import { cleanComponent, cleanDatasource } from "./serialize"; + +type EntityType = "component" | "datasource"; + +/** Canonical string for deep value equality; `formatValue` sorts keys recursively. */ +function canonical(value: unknown): string { + return formatValue(value, 0); +} + +/** + * Classifies field-level changes between two name-keyed objects. A key present on + * only one side is `added`/`removed`; a key on both whose canonical form differs + * is `modified`. Keys are compared in stable alphabetical order. + */ +function diffKeyed(before: Record, after: Record): FieldChange[] { + const changes: FieldChange[] = []; + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + + for (const field of [...keys].sort()) { + const inBefore = field in before; + const inAfter = field in after; + if (inBefore && !inAfter) { + changes.push({ field, change: "removed", before: before[field] }); + } else if (!inBefore && inAfter) { + changes.push({ field, change: "added", after: after[field] }); + } else if (canonical(before[field]) !== canonical(after[field])) { + changes.push({ field, change: "modified", before: before[field], after: after[field] }); + } + } + + return changes; +} + +function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +/** + * Field-level changes for a component: top-level props (display_name, is_nestable, + * component_group_uuid, …) and, expanded one level, individual schema fields. + */ +function componentChanges( + before: Record, + after: Record, +): FieldChange[] { + const { schema: beforeSchema, ...beforeProps } = before; + const { schema: afterSchema, ...afterProps } = after; + return [ + ...diffKeyed(beforeProps, afterProps), + ...diffKeyed(asRecord(beforeSchema), asRecord(afterSchema)), + ]; +} + +/** Builds an {@link EntityDiff} from the cleaned source/target objects. */ +function buildEntityDiff( + type: EntityType, + name: string, + fromRaw: Record | null, + toRaw: Record | null, + fromClean: Record | null, + toClean: Record | null, +): EntityDiff { + if (!fromClean && toClean) { + return { type, name, action: "create", changes: [], before: null, after: toRaw }; + } + if (fromClean && !toClean) { + return { type, name, action: "stale", changes: [], before: fromRaw, after: null }; + } + if (canonical(fromClean) === canonical(toClean)) { + return { type, name, action: "unchanged", changes: [], before: fromRaw, after: toRaw }; + } + + const changes = + type === "component" ? componentChanges(fromClean!, toClean!) : diffKeyed(fromClean!, toClean!); + + return { type, name, action: "update", changes, before: fromRaw, after: toRaw }; +} + +/** Names of `to` in insertion order, then any `from`-only names — mirrors the target's order. */ +function orderedNames(from: Map, to: Map): string[] { + const names = [...to.keys()]; + for (const name of from.keys()) { + if (!to.has(name)) { + names.push(name); + } + } + return names; +} + +function diffComponent( + name: string, + fromComp: Component | undefined, + toComp: Component | undefined, + compareGroupUuid: boolean, +): EntityDiff { + // Group UUIDs are per-space identifiers, so they only carry meaning when the + // caller opts in (push, where the target is the local DSL and an explicit + // `component_group_uuid` is a deliberate escape hatch). When comparing two + // spaces they never match and would flag every grouped block as changed, so + // the field stays stripped on both sides unless both are opted in. + const includeGroupUuid = compareGroupUuid && typeof toComp?.component_group_uuid === "string"; + const fromClean = fromComp + ? cleanComponent(applyDefaults(fromComp, COMPONENT_DEFAULTS), { includeGroupUuid }) + : null; + const toClean = toComp + ? cleanComponent(applyDefaults(toComp, COMPONENT_DEFAULTS), { includeGroupUuid }) + : null; + return buildEntityDiff("component", name, fromComp ?? null, toComp ?? null, fromClean, toClean); +} + +function diffDatasource( + name: string, + fromDs: Datasource | undefined, + toDs: Datasource | undefined, +): EntityDiff { + const fromClean = fromDs ? cleanDatasource(applyDefaults(fromDs, DATASOURCE_DEFAULTS)) : null; + const toClean = toDs ? cleanDatasource(applyDefaults(toDs, DATASOURCE_DEFAULTS)) : null; + return buildEntityDiff("datasource", name, fromDs ?? null, toDs ?? null, fromClean, toClean); +} + +/** + * Folders (component groups) are identified by slug path. Renames are + * unsupported, so a folder is only ever `create` (target-only), `stale` + * (source-only), or `unchanged` — display names matter at creation only, and + * there are no field-level changes. {@link EntityDiff.name} carries the path. + */ +function diffFolder( + name: string, + fromFolder: LocalFolder | undefined, + toFolder: LocalFolder | undefined, +): EntityDiff { + const before = fromFolder ? { ...fromFolder } : null; + const after = toFolder ? { ...toFolder } : null; + const action = + !fromFolder && toFolder ? "create" : fromFolder && !toFolder ? "stale" : "unchanged"; + return { type: "folder", name, action, changes: [], before, after }; +} + +/** + * Diffs two normalized schemas and returns classified results describing how to + * get from `from` (base) to `to` (target): entities only in `to` are `create`, + * only in `from` are `stale`, in both and differing are `update` (with + * field-level `changes`), otherwise `unchanged`. + * + * Folders (component groups) are diffed by slug path. Component group UUIDs are + * ignored by default (they are per-space identifiers); set `compareGroupUuid` + * when the target is a local DSL, so a block that sets `component_group_uuid` + * explicitly opts into having its group membership diffed and pushed. + */ +export function diffSchema( + from: NormalizedSchema, + to: NormalizedSchema, + options: { compareGroupUuid?: boolean } = {}, +): DiffResult { + const compareGroupUuid = options.compareGroupUuid ?? false; + const diffs: EntityDiff[] = []; + + // Folders first: `schema push` creates them parent-first before the blocks + // that reference them. + for (const name of orderedNames(from.folders, to.folders)) { + diffs.push(diffFolder(name, from.folders.get(name), to.folders.get(name))); + } + + for (const name of orderedNames(from.components, to.components)) { + diffs.push( + diffComponent(name, from.components.get(name), to.components.get(name), compareGroupUuid), + ); + } + + for (const name of orderedNames(from.datasources, to.datasources)) { + diffs.push(diffDatasource(name, from.datasources.get(name), to.datasources.get(name))); + } + + return { + diffs, + creates: diffs.filter((d) => d.action === "create").length, + updates: diffs.filter((d) => d.action === "update").length, + unchanged: diffs.filter((d) => d.action === "unchanged").length, + stale: diffs.filter((d) => d.action === "stale").length, + }; +} diff --git a/packages/cli/src/commands/schema/diff/README.md b/packages/cli/src/commands/schema/diff/README.md new file mode 100644 index 000000000..a217b5cc0 --- /dev/null +++ b/packages/cli/src/commands/schema/diff/README.md @@ -0,0 +1,61 @@ +# Schema Diff Command + +The `schema diff` command compares two schemas and reports what changed. Each side can be a remote +space (by ID) or a local schema entry file, so you can diff space against space, file against space, +or file against file. + +## Basic Usage + +Diff two spaces: + +```bash +storyblok schema diff --from SOURCE_SPACE_ID --to TARGET_SPACE_ID +``` + +Diff a local schema entry file against a space: + +```bash +storyblok schema diff --from ./schema/index.ts --to TARGET_SPACE_ID +``` + +Each source is auto-detected: a numeric value is treated as a space ID (fetched remotely), anything +else is treated as a path to a schema entry file (loaded locally). Authentication is required only +when a side points at a space. + +## Options + +| Option | Description | Default | +| ----------------- | -------------------------------------------------------------------------------------- | ------- | +| `--from ` | (Required) Base schema to compare against: a space ID or a path to a schema entry file | - | +| `--to ` | (Required) Target schema: a space ID or a path to a schema entry file | - | + +## Output + +By default the command prints a human-readable diff to the terminal, grouping entities into added, +changed, and removed relative to `--to`, with field-level changes for each modified entity. + +The full structured diff is also emitted through the reporter when reports are enabled (via the +global `--report-enabled` flag). The report's `meta.diff` carries the entity-level actions and +field-level changes, which is the machine-readable "diff file" downstream tooling reads, for example +when replicating schema changes from one space to another. + +The machine-readable payload uses the same vocabulary as `schema push`, which differs from the human +wording: + +- `meta.diff.entities[].action` is one of `create`, `update`, `stale`, or `unchanged` (the human + output relabels these to added, changed, and removed relative to `--to`). +- `meta.diff.entities[].changes[].change` is one of `added`, `removed`, or `modified`, each with + `before` and `after` values. + +## Notes + +- The direction matters: `--from` is the base and `--to` is the target. An entity present only in + `--to` is reported as added, present only in `--from` as removed, and present in both but + differing as changed. +- Unchanged entities are omitted from the terminal output to keep space-to-space comparisons + readable. They still appear in the summary count and in the structured `meta.diff`. +- Component group UUIDs are per-space identifiers, so they are ignored unless both sides are local + files. A component that differs only by its group assignment between two spaces is reported as + unchanged. +- The classification is the same one `schema push` computes internally, exposed here as a read-only + command. diff --git a/packages/cli/src/commands/schema/diff/actions.test.ts b/packages/cli/src/commands/schema/diff/actions.test.ts new file mode 100644 index 000000000..fac72aade --- /dev/null +++ b/packages/cli/src/commands/schema/diff/actions.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; + +import type { DiffResult, EntityDiff } from "../types"; +import { buildDiffReport, formatSchemaDiff, isSpaceRef } from "./actions"; + +function makeResult(diffs: EntityDiff[]): DiffResult { + return { + diffs, + creates: diffs.filter((d) => d.action === "create").length, + updates: diffs.filter((d) => d.action === "update").length, + unchanged: diffs.filter((d) => d.action === "unchanged").length, + stale: diffs.filter((d) => d.action === "stale").length, + }; +} + +describe("isSpaceRef", () => { + it("should treat a numeric string as a space ID", () => { + expect(isSpaceRef("12345")).toBe(true); + expect(isSpaceRef(" 678 ")).toBe(true); + }); + + it("should treat a non-numeric string as a file path", () => { + expect(isSpaceRef("./schema.ts")).toBe(false); + expect(isSpaceRef("src/schema/index.ts")).toBe(false); + expect(isSpaceRef("12/schema.ts")).toBe(false); + }); +}); + +describe("buildDiffReport", () => { + it("should carry the summary counts and full entity list", () => { + const result = makeResult([ + { + type: "component", + name: "hero", + action: "create", + changes: [], + before: null, + after: { name: "hero" }, + }, + { + type: "component", + name: "footer", + action: "stale", + changes: [], + before: { name: "footer" }, + after: null, + }, + ]); + + const report = buildDiffReport(result, "111", "222"); + + expect(report.from).toBe("111"); + expect(report.to).toBe("222"); + expect(report.summary).toEqual({ create: 1, update: 0, unchanged: 0, stale: 1 }); + expect(report.entities).toHaveLength(2); + expect(report.entities[0]).toMatchObject({ name: "hero", action: "create" }); + }); +}); + +describe("formatSchemaDiff", () => { + it("should use direction-aware wording (added/changed/removed)", () => { + const result = makeResult([ + { + type: "component", + name: "hero", + action: "create", + changes: [], + before: null, + after: { name: "hero" }, + }, + { + type: "component", + name: "teaser", + action: "update", + changes: [ + { + field: "headline", + change: "modified", + before: { type: "text" }, + after: { type: "textarea" }, + }, + ], + before: {}, + after: {}, + }, + { + type: "datasource", + name: "colors", + action: "stale", + changes: [], + before: { name: "colors" }, + after: null, + }, + ]); + + const output = formatSchemaDiff(result, "111", "222"); + + expect(output).toContain("from 111 → to 222"); + expect(output).toContain("hero (added)"); + expect(output).toContain("teaser (changed)"); + expect(output).toContain("colors (removed)"); + expect(output).toContain("headline"); + expect(output).toContain("1 added, 1 changed, 1 removed"); + }); + + it("should report no differences when nothing changed", () => { + const result = makeResult([ + { type: "component", name: "hero", action: "unchanged", changes: [], before: {}, after: {} }, + ]); + + const output = formatSchemaDiff(result, "a.ts", "b.ts"); + + expect(output).toContain("1 unchanged"); + }); + + it("should omit unchanged entities from the listing while keeping the summary count", () => { + const result = makeResult([ + { + type: "component", + name: "hero", + action: "create", + changes: [], + before: null, + after: { name: "hero" }, + }, + { + type: "component", + name: "footer", + action: "unchanged", + changes: [], + before: {}, + after: {}, + }, + ]); + + const output = formatSchemaDiff(result, "111", "222"); + + expect(output).toContain("hero (added)"); + expect(output).not.toContain("footer"); + expect(output).not.toContain("(unchanged)"); + expect(output).toContain("1 unchanged"); + }); +}); diff --git a/packages/cli/src/commands/schema/diff/actions.ts b/packages/cli/src/commands/schema/diff/actions.ts new file mode 100644 index 000000000..c407afedf --- /dev/null +++ b/packages/cli/src/commands/schema/diff/actions.ts @@ -0,0 +1,77 @@ +import type { DiffResult, EntityDiff, NormalizedSchema } from "../types"; +import { fetchRemoteSchema, localToNormalized, remoteToNormalized } from "../actions"; +import { formatDiff } from "../format-diff"; +import { loadSchema } from "../load-schema"; + +/** A schema source: a numeric space ID or a path to a schema entry file. */ +export function isSpaceRef(ref: string): boolean { + return /^\d+$/.test(ref.trim()); +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Resolves a source ref to a {@link NormalizedSchema}: numeric → remote space, + * otherwise → local entry file. `label` (e.g. `--from`) names the side in errors. + */ +export async function resolveSource(ref: string, label: string): Promise { + const value = ref.trim(); + if (isSpaceRef(value)) { + try { + const { remote } = await fetchRemoteSchema(value); + return remoteToNormalized(remote); + } catch (error) { + throw new Error( + `Could not load space "${value}" (${label}): ${describeError(error)}. Check the space ID and that you are logged in with access to it.`, + ); + } + } + try { + return localToNormalized(await loadSchema(value)); + } catch (error) { + throw new Error( + `Could not load schema entry file "${value}" (${label}): ${describeError(error)}. Check the path, and that it is a project where the schema package and its dependencies are installed.`, + ); + } +} + +/** Machine-readable diff payload emitted via the reporter's `meta.diff`. */ +export interface SchemaDiffReport { + from: string; + to: string; + summary: { create: number; update: number; unchanged: number; stale: number }; + entities: EntityDiff[]; +} + +/** Builds the serializable diff payload for the reporter. */ +export function buildDiffReport(result: DiffResult, from: string, to: string): SchemaDiffReport { + return { + from, + to, + summary: { + create: result.creates, + update: result.updates, + unchanged: result.unchanged, + stale: result.stale, + }, + entities: result.diffs, + }; +} + +/** + * Formats the diff for human terminal output with direction-aware wording. + * Unchanged entities are omitted from the listing (they stay in the summary + * count and in `meta.diff`) to keep space-to-space output readable. + */ +export function formatSchemaDiff(result: DiffResult, from: string, to: string): string { + const labels = { create: "added", update: "changed", unchanged: "unchanged", stale: "removed" }; + return formatDiff(result, { + header: `from ${from} → to ${to}`, + tags: labels, + summary: labels, + showUnchanged: false, + emptySummary: "no differences", + }); +} diff --git a/packages/cli/src/commands/schema/diff/index.test.ts b/packages/cli/src/commands/schema/diff/index.test.ts new file mode 100644 index 000000000..a03ff71dd --- /dev/null +++ b/packages/cli/src/commands/schema/diff/index.test.ts @@ -0,0 +1,153 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { vol } from "memfs"; + +import "../index"; +import { schemaCommand } from "../command"; +import type { SchemaData } from "../types"; +import { resetReporter } from "../../../lib/reporter/reporter"; +import { loadSchema } from "../load-schema"; + +// loadSchema uses jiti to import TypeScript entry files at runtime, which cannot +// resolve @storyblok/schema in the test environment; mock it and feed data directly. +vi.mock("../load-schema", () => ({ + loadSchema: vi.fn(), +})); + +vi.spyOn(console, "log"); + +const server = setupServer(); + +interface MockComponent { + id: number; + name: string; + created_at: string; + updated_at: string; + schema: Record>; +} + +function comp( + name: string, + schema: Record>, + id = 1, +): MockComponent { + return { id, name, created_at: "2024-01-01", updated_at: "2024-01-01", schema }; +} + +/** Registers the three GET endpoints `fetchRemoteSchema` needs for a space. */ +function spaceWith(space: string, components: MockComponent[]) { + server.use( + http.get(`https://mapi.storyblok.com/v1/spaces/${space}/components`, () => + HttpResponse.json({ components }), + ), + http.get(`https://mapi.storyblok.com/v1/spaces/${space}/component_groups`, () => + HttpResponse.json({ component_groups: [] }), + ), + http.get(`https://mapi.storyblok.com/v1/spaces/${space}/datasources`, () => + HttpResponse.json({ datasources: [] }), + ), + ); +} + +/** Reads the written diff report from the virtual filesystem. */ +function getDiffReport() { + const file = Object.entries(vol.toJSON()).find( + ([name]) => name.includes("schema-diff") && name.endsWith(".json"), + ); + return file ? JSON.parse(file[1] as string) : undefined; +} + +describe("schema diff command", () => { + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + + afterEach(() => { + vi.resetAllMocks(); + vi.clearAllMocks(); + vol.reset(); + server.resetHandlers(); + resetReporter(); + }); + + afterAll(() => server.close()); + + it("should diff two remote spaces and classify created, changed, and unchanged entities", async () => { + spaceWith("111", [comp("hero", { title: { type: "text", pos: 0 } })]); + spaceWith("222", [ + comp("hero", { title: { type: "text", pos: 0 }, subtitle: { type: "text", pos: 1 } }, 2), + comp("banner", { image: { type: "asset", pos: 0 } }, 3), + ]); + + await schemaCommand.parseAsync(["node", "test", "diff", "--from", "111", "--to", "222"]); + + const report = getDiffReport(); + expect(report?.meta.diff.summary).toMatchObject({ create: 1, update: 1 }); + const entities = report.meta.diff.entities as { name: string; action: string }[]; + expect(entities.find((e) => e.name === "banner")?.action).toBe("create"); + expect(entities.find((e) => e.name === "hero")?.action).toBe("update"); + }); + + it("should carry field-level changes in the report payload", async () => { + spaceWith("111", [comp("hero", { title: { type: "text", pos: 0 } })]); + spaceWith("222", [ + comp("hero", { title: { type: "text", pos: 0 }, subtitle: { type: "text", pos: 1 } }, 2), + ]); + + await schemaCommand.parseAsync(["node", "test", "diff", "--from", "111", "--to", "222"]); + + const report = getDiffReport(); + const hero = ( + report.meta.diff.entities as { name: string; changes: { field: string; change: string }[] }[] + ).find((e) => e.name === "hero"); + expect(hero?.changes.some((c) => c.field === "subtitle" && c.change === "added")).toBe(true); + }); + + it("should diff a local entry file against a remote space", async () => { + const local: SchemaData = { + components: [ + comp("hero", { + title: { type: "text", pos: 0 }, + }) as unknown as SchemaData["components"][number], + ], + datasources: [], + folders: [], + }; + vi.mocked(loadSchema).mockResolvedValue(local); + spaceWith("222", []); + + await schemaCommand.parseAsync([ + "node", + "test", + "diff", + "--from", + "./schema.ts", + "--to", + "222", + ]); + + expect(loadSchema).toHaveBeenCalledWith("./schema.ts"); + const report = getDiffReport(); + // Local (to=remote 222 is empty, from=file has hero) → hero exists only in `from` → stale. + expect(report?.meta.diff.summary).toMatchObject({ stale: 1 }); + }); + + it("should report which side failed to resolve when a file cannot be loaded", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(loadSchema).mockRejectedValue(new Error("Cannot find module /abs/missing.ts")); + spaceWith("222", []); + + await schemaCommand.parseAsync([ + "node", + "test", + "diff", + "--from", + "./missing.ts", + "--to", + "222", + ]); + + const message = consoleError.mock.calls.flat().join(" "); + expect(message).toContain("--from"); + expect(message).toContain("schema entry file"); + }); +}); diff --git a/packages/cli/src/commands/schema/diff/index.ts b/packages/cli/src/commands/schema/diff/index.ts new file mode 100644 index 000000000..3dd4739bc --- /dev/null +++ b/packages/cli/src/commands/schema/diff/index.ts @@ -0,0 +1,80 @@ +import { colorPalette, commands } from "../../../constants"; +import { handleError, requireAuthentication, toError } from "../../../utils"; +import { getLogger } from "../../../lib/logger/logger"; +import { getReporter } from "../../../lib/reporter/reporter"; +import { getUI } from "../../../lib/ui"; +import { session } from "../../../session"; +import { schemaCommand } from "../command"; +import { diffSchema } from "../diff-schema"; +import type { SchemaDiffReport } from "./actions"; +import { buildDiffReport, formatSchemaDiff, isSpaceRef, resolveSource } from "./actions"; + +schemaCommand + .command("diff") + .description("Diff two schemas (space IDs or local entry files) and report what changed") + .requiredOption( + "--from ", + "Base schema to compare against: a space ID or a path to a schema entry file", + ) + .requiredOption("--to ", "Target schema: a space ID or a path to a schema entry file") + .action(async (options: { from: string; to: string }, command) => { + const ui = getUI(); + const logger = getLogger(); + const reporter = getReporter(); + const { verbose } = command.optsWithGlobals(); + const { state } = session(); + const { from, to } = options; + + ui.title(commands.SCHEMA, colorPalette.SCHEMA, "Diffing schema..."); + logger.info("Schema diff started", { from, to }); + + // Authentication is only required when a side points at a remote space. + if ((isSpaceRef(from) || isSpaceRef(to)) && !requireAuthentication(state, verbose)) { + return; + } + + const summary = { total: 0, succeeded: 0, failed: 0 }; + let report: SchemaDiffReport | undefined; + + try { + const resolveSpinner = ui.createSpinner("Resolving schemas..."); + let fromSchema: Awaited>; + let toSchema: Awaited>; + try { + [fromSchema, toSchema] = await Promise.all([ + resolveSource(from, "--from"), + resolveSource(to, "--to"), + ]); + } catch (maybeError) { + resolveSpinner.failed("Failed to resolve schemas"); + handleError(toError(maybeError), verbose); + return; + } + resolveSpinner.succeed("Schemas resolved"); + + // Group UUIDs are per-space identifiers, so they are meaningless to + // compare against a remote space. Only diff them when both sides are local + // files, where an explicit `component_group_uuid` is a deliberate choice. + const compareGroupUuid = !isSpaceRef(from) && !isSpaceRef(to); + const diffResult = diffSchema(fromSchema, toSchema, { compareGroupUuid }); + report = buildDiffReport(diffResult, from, to); + + ui.br(); + ui.log(formatSchemaDiff(diffResult, from, to)); + + summary.total = diffResult.diffs.length; + summary.succeeded = summary.total; + } catch (maybeError) { + summary.failed += 1; + handleError(toError(maybeError), verbose); + } finally { + logger.info("Schema diff finished", { summary }); + reporter.addSummary("schemaDiffResults", summary); + // The full structured diff travels in the report's meta — the machine-readable + // "diff file" downstream space-to-space tooling reads (enabled via --report-enabled). + if (report) { + reporter.addMeta("diff", report); + } + reporter.finalize(); + } + }); diff --git a/packages/cli/src/commands/schema/format-diff.ts b/packages/cli/src/commands/schema/format-diff.ts new file mode 100644 index 000000000..b192fbd37 --- /dev/null +++ b/packages/cli/src/commands/schema/format-diff.ts @@ -0,0 +1,116 @@ +import chalk from "chalk"; + +import type { DiffAction, DiffResult, EntityDiff, FieldChange } from "./types"; + +/** Formats a field value on a single line for terminal display. */ +function inlineValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + return JSON.stringify(value) ?? String(value); +} + +/** + * Renders field-level changes as colored, indented terminal lines: added fields in + * green, removed in red, and modified as a red `before` / green `after` pair. + * Shared by the `push` diff display and the `schema diff` command. + */ +export function renderFieldChanges(changes: FieldChange[], indent = " "): string[] { + const lines: string[] = []; + + for (const change of changes) { + if (change.change === "added") { + lines.push(chalk.green(`${indent}+ ${change.field}: ${inlineValue(change.after)}`)); + } else if (change.change === "removed") { + lines.push(chalk.red(`${indent}- ${change.field}: ${inlineValue(change.before)}`)); + } else { + lines.push(`${indent}~ ${change.field}`); + lines.push(chalk.red(`${indent} - ${inlineValue(change.before)}`)); + lines.push(chalk.green(`${indent} + ${inlineValue(change.after)}`)); + } + } + + return lines; +} + +/** Per-action wording for entity tags and the summary line. */ +export interface DiffLabelSet { + create: string; + update: string; + unchanged: string; + stale: string; +} + +export interface FormatDiffOptions { + /** Optional dimmed header line, e.g. `from A → to B`. */ + header?: string; + /** Per-entity action tag wording, e.g. `(create)` or `(added)`. */ + tags: DiffLabelSet; + /** Summary line wording, e.g. `3 to create` or `3 added`. */ + summary: DiffLabelSet; + /** List unchanged entities in the per-entity output (default `true`). The summary count is unaffected. */ + showUnchanged?: boolean; + /** Text shown after `Summary:` when there are no changes at all. */ + emptySummary?: string; +} + +const ACTION_ICONS: Record = { + create: chalk.green("+"), + update: chalk.yellow("~"), + unchanged: chalk.dim("="), + stale: chalk.red("-"), +}; + +const SECTIONS: [string, EntityDiff["type"]][] = [ + ["Folders", "folder"], + ["Components", "component"], + ["Datasources", "datasource"], +]; + +/** + * Renders a {@link DiffResult} for terminal display: a `+/~/-/=` icon and action + * tag per entity, field-level changes, and a colored summary line. Wording is + * supplied via {@link FormatDiffOptions} so both `schema push` and `schema diff` + * share one layout while keeping their own vocabulary. + */ +export function formatDiff(result: DiffResult, options: FormatDiffOptions): string { + const showUnchanged = options.showUnchanged ?? true; + const lines: string[] = []; + + if (options.header) { + lines.push(chalk.dim(options.header)); + lines.push(""); + } + + for (const [label, type] of SECTIONS) { + const diffs = result.diffs.filter( + (d) => d.type === type && (showUnchanged || d.action !== "unchanged"), + ); + if (diffs.length === 0) { + continue; + } + + lines.push(chalk.bold(label)); + for (const diff of diffs) { + const name = diff.action === "stale" ? chalk.red(diff.name) : diff.name; + lines.push( + ` ${ACTION_ICONS[diff.action]} ${name} ${chalk.dim(`(${options.tags[diff.action]})`)}`, + ); + lines.push(...renderFieldChanges(diff.changes)); + } + lines.push(""); + } + + const summary = [ + result.creates > 0 ? chalk.green(`${result.creates} ${options.summary.create}`) : null, + result.updates > 0 ? chalk.yellow(`${result.updates} ${options.summary.update}`) : null, + result.stale > 0 ? chalk.red(`${result.stale} ${options.summary.stale}`) : null, + result.unchanged > 0 ? chalk.dim(`${result.unchanged} ${options.summary.unchanged}`) : null, + ] + .filter(Boolean) + .join(", "); + + lines.push(`Summary: ${summary || options.emptySummary || ""}`); + + return lines.join("\n"); +} diff --git a/packages/cli/src/commands/schema/index.ts b/packages/cli/src/commands/schema/index.ts index 9875da616..e4e60f2ee 100644 --- a/packages/cli/src/commands/schema/index.ts +++ b/packages/cli/src/commands/schema/index.ts @@ -1,5 +1,6 @@ import "./command"; import "./push"; +import "./diff"; import "./init"; import "./rollback"; import "./validate"; diff --git a/packages/cli/src/commands/schema/push/load-schema.test.ts b/packages/cli/src/commands/schema/load-schema.test.ts similarity index 100% rename from packages/cli/src/commands/schema/push/load-schema.test.ts rename to packages/cli/src/commands/schema/load-schema.test.ts diff --git a/packages/cli/src/commands/schema/push/load-schema.ts b/packages/cli/src/commands/schema/load-schema.ts similarity index 95% rename from packages/cli/src/commands/schema/push/load-schema.ts rename to packages/cli/src/commands/schema/load-schema.ts index 48a125a80..bbed8078c 100644 --- a/packages/cli/src/commands/schema/push/load-schema.ts +++ b/packages/cli/src/commands/schema/load-schema.ts @@ -1,8 +1,8 @@ -import type { LocalFolder, SchemaData } from "../types"; -import { CommandError, isRecord } from "../../../utils"; -import { collectSchemaExports, loadSchemaModule } from "../../../utils/schema/classify-exports"; -import { expandFolderPath } from "../folders"; -import { mapBlockToWire, mapDatasourceToWire } from "../map-to-wire"; +import type { LocalFolder, SchemaData } from "./types"; +import { CommandError, isRecord } from "../../utils"; +import { collectSchemaExports, loadSchemaModule } from "../../utils/schema/classify-exports"; +import { expandFolderPath } from "./folders"; +import { mapBlockToWire, mapDatasourceToWire } from "./map-to-wire"; /** * Builds the deduped, parent-first {@link LocalFolder} list from harvested diff --git a/packages/cli/src/commands/schema/push/actions.test.ts b/packages/cli/src/commands/schema/push/actions.test.ts index 49ffecd5c..284b96d22 100644 --- a/packages/cli/src/commands/schema/push/actions.test.ts +++ b/packages/cli/src/commands/schema/push/actions.test.ts @@ -309,14 +309,14 @@ describe("buildChangesetEntries", () => { it("should map create and update actions correctly", () => { const diffResult = makeDiffResult([ - { type: "component", name: "hero", action: "update", diff: null, local: null, remote: null }, + { type: "component", name: "hero", action: "update", changes: [], before: null, after: null }, { type: "component", name: "new-comp", action: "create", - diff: null, - local: null, - remote: null, + changes: [], + before: null, + after: null, }, ]); const local: SchemaData = { @@ -338,9 +338,9 @@ describe("buildChangesetEntries", () => { type: "component", name: "hero", action: "unchanged", - diff: null, - local: null, - remote: null, + changes: [], + before: null, + after: null, }, ]); @@ -351,7 +351,14 @@ describe("buildChangesetEntries", () => { it("should skip stale entries when delete option is false", () => { const diffResult = makeDiffResult([ - { type: "component", name: "footer", action: "stale", diff: null, local: null, remote: null }, + { + type: "component", + name: "footer", + action: "stale", + changes: [], + before: null, + after: null, + }, ]); const changes = buildChangesetEntries(diffResult, baseLocal, baseRemote, { delete: false }); @@ -361,7 +368,14 @@ describe("buildChangesetEntries", () => { it("should include stale as delete when delete option is true", () => { const diffResult = makeDiffResult([ - { type: "component", name: "footer", action: "stale", diff: null, local: null, remote: null }, + { + type: "component", + name: "footer", + action: "stale", + changes: [], + before: null, + after: null, + }, ]); const changes = buildChangesetEntries(diffResult, baseLocal, baseRemote, { delete: true }); @@ -372,7 +386,7 @@ describe("buildChangesetEntries", () => { it("should include before/after snapshots", () => { const diffResult = makeDiffResult([ - { type: "component", name: "hero", action: "update", diff: null, local: null, remote: null }, + { type: "component", name: "hero", action: "update", changes: [], before: null, after: null }, ]); const changes = buildChangesetEntries(diffResult, baseLocal, baseRemote, { delete: false }); @@ -968,9 +982,9 @@ describe("formatDiffOutput", () => { type: "datasource", name: "Page Categories", action: "stale", - diff: null, - local: null, - remote: null, + changes: [], + before: null, + after: null, }, ]); @@ -986,9 +1000,9 @@ describe("formatDiffOutput", () => { type: "datasource", name: "Page Categories", action: "stale", - diff: null, - local: null, - remote: null, + changes: [], + before: null, + after: null, }, ]); diff --git a/packages/cli/src/commands/schema/push/actions.ts b/packages/cli/src/commands/schema/push/actions.ts index 6987d3972..58681cfe6 100644 --- a/packages/cli/src/commands/schema/push/actions.ts +++ b/packages/cli/src/commands/schema/push/actions.ts @@ -1,16 +1,14 @@ -import chalk from "chalk"; - import type { Component } from "../../../types"; import type { ChangesetEntry, DiffResult, - EntityDiff, LocalFolder, RemoteSchemaData, SchemaData, } from "../types"; import { getMapiClient } from "../../../api"; import { CommandError, handleAPIError } from "../../../utils"; +import { formatDiff } from "../format-diff"; import { toComponentCreate, toComponentUpdate, @@ -87,71 +85,23 @@ function buildGroupByPath(remote: RemoteSchemaData): Map { return groupByPath; } -/** Formats diff results for CLI display using chalk colors. */ +/** Formats diff results for `schema push` display using chalk colors. */ export function formatDiffOutput(result: DiffResult, options?: { delete?: boolean }): string { - const lines: string[] = []; - - const byType = { - component: [] as EntityDiff[], - datasource: [] as EntityDiff[], - folder: [] as EntityDiff[], - }; - - for (const diff of result.diffs) { - byType[diff.type].push(diff); - } - const willDelete = options?.delete ?? false; - const icons: Record = { - create: chalk.green("+"), - update: chalk.yellow("~"), - unchanged: chalk.dim("="), - stale: chalk.red("-"), - }; - - const sections: [string, EntityDiff[]][] = [ - ["Folders", byType.folder], - ["Components", byType.component], - ["Datasources", byType.datasource], - ]; - - for (const [label, diffs] of sections) { - if (diffs.length === 0) { - continue; - } - - lines.push(chalk.bold(label)); - for (const diff of diffs) { - const icon = icons[diff.action] ?? " "; - const name = diff.action === "stale" ? chalk.red(diff.name) : diff.name; - const actionLabel = diff.action === "stale" && willDelete ? "delete" : diff.action; - lines.push(` ${icon} ${name} ${chalk.dim(`(${actionLabel})`)}`); - - if (diff.diff) { - for (const line of diff.diff.split("\n")) { - if (line.startsWith("+") && !line.startsWith("+++")) { - lines.push(` ${chalk.green(line)}`); - } else if (line.startsWith("-") && !line.startsWith("---")) { - lines.push(` ${chalk.red(line)}`); - } - } - } - } - lines.push(""); - } - - const summary = [ - result.creates > 0 ? chalk.green(`${result.creates} to create`) : null, - result.updates > 0 ? chalk.yellow(`${result.updates} to update`) : null, - result.unchanged > 0 ? chalk.dim(`${result.unchanged} unchanged`) : null, - result.stale > 0 ? chalk.red(`${result.stale} ${willDelete ? "to delete" : "stale"}`) : null, - ] - .filter(Boolean) - .join(", "); - - lines.push(`Summary: ${summary}`); - - return lines.join("\n"); + return formatDiff(result, { + tags: { + create: "create", + update: "update", + unchanged: "unchanged", + stale: willDelete ? "delete" : "stale", + }, + summary: { + create: "to create", + update: "to update", + unchanged: "unchanged", + stale: willDelete ? "to delete" : "stale", + }, + }); } /** Pushes local schema changes to the remote space. */ diff --git a/packages/cli/src/commands/schema/push/diff-schema.test.ts b/packages/cli/src/commands/schema/push/diff-schema.test.ts deleted file mode 100644 index 3499237c1..000000000 --- a/packages/cli/src/commands/schema/push/diff-schema.test.ts +++ /dev/null @@ -1,439 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { RemoteSchemaData, SchemaData } from "../types"; -import { diffSchema } from "./diff-schema"; - -function makeComponent(name: string, schema: Record) { - return { id: 1, name, created_at: "", updated_at: "", schema } as any; -} - -function makeDatasource(name: string, slug: string) { - return { id: 1, name, slug, created_at: "", updated_at: "" } as any; -} - -describe("diffSchema", () => { - it("should detect new entities as create", () => { - const local: SchemaData = { - components: [makeComponent("page", { title: { type: "text", pos: 0 } })], - folders: [], - datasources: [], - }; - const remote: RemoteSchemaData = { - components: new Map(), - componentFolders: new Map(), - datasources: new Map(), - }; - - const result = diffSchema(local, remote); - - expect(result.creates).toBe(1); - expect(result.diffs[0].action).toBe("create"); - expect(result.diffs[0].name).toBe("page"); - }); - - it("should detect unchanged entities", () => { - const comp = makeComponent("page", { title: { type: "text", pos: 0 } }); - const local: SchemaData = { - components: [comp], - folders: [], - datasources: [], - }; - const remote: RemoteSchemaData = { - components: new Map([["page", { ...comp, id: 99 }]]), - componentFolders: new Map(), - datasources: new Map(), - }; - - const result = diffSchema(local, remote); - - expect(result.unchanged).toBe(1); - expect(result.diffs[0].action).toBe("unchanged"); - }); - - it("should detect updated entities with diff string", () => { - const localComp = makeComponent("page", { title: { type: "text", pos: 0, max_length: 70 } }); - const remoteComp = makeComponent("page", { title: { type: "text", pos: 0, max_length: 60 } }); - - const local: SchemaData = { components: [localComp], folders: [], datasources: [] }; - const remote: RemoteSchemaData = { - components: new Map([["page", { ...remoteComp, id: 99 }]]), - componentFolders: new Map(), - datasources: new Map(), - }; - - const result = diffSchema(local, remote); - - expect(result.updates).toBe(1); - expect(result.diffs[0].action).toBe("update"); - expect(result.diffs[0].diff).toContain("max_length"); - }); - - it("should detect stale remote entities", () => { - const local: SchemaData = { components: [], folders: [], datasources: [] }; - const remote: RemoteSchemaData = { - components: new Map([["footer", makeComponent("footer", {})]]), - componentFolders: new Map(), - datasources: new Map(), - }; - - const result = diffSchema(local, remote); - - expect(result.stale).toBe(1); - expect(result.diffs[0].action).toBe("stale"); - expect(result.diffs[0].name).toBe("footer"); - }); - - it("should not show diff for auto-populated defaults (e.g. internal_tag_ids)", () => { - const localComp = makeComponent("page", { title: { type: "text", pos: 0 } }); - // Remote has internal_tag_ids: [] auto-populated by Storyblok, local doesn't set it - const remoteComp = { - ...makeComponent("page", { title: { type: "text", pos: 0 } }), - internal_tag_ids: [], - }; - - const local: SchemaData = { components: [localComp], folders: [], datasources: [] }; - const remote: RemoteSchemaData = { - components: new Map([["page", remoteComp]]), - componentFolders: new Map(), - datasources: new Map(), - }; - - const result = diffSchema(local, remote); - - expect(result.unchanged).toBe(1); - expect(result.updates).toBe(0); - expect(result.diffs[0].action).toBe("unchanged"); - }); - - it("should show diff when local explicitly sets internal_tag_ids differently from remote", () => { - const localComp = { - ...makeComponent("page", { title: { type: "text", pos: 0 } }), - internal_tag_ids: [10], - }; - const remoteComp = { - ...makeComponent("page", { title: { type: "text", pos: 0 } }), - internal_tag_ids: [], - }; - - const local: SchemaData = { components: [localComp], folders: [], datasources: [] }; - const remote: RemoteSchemaData = { - components: new Map([["page", remoteComp]]), - componentFolders: new Map(), - datasources: new Map(), - }; - - const result = diffSchema(local, remote); - - expect(result.updates).toBe(1); - expect(result.diffs[0].action).toBe("update"); - }); - - it("should not show diff when remote has an empty description and local does not set it", () => { - const localComp = makeComponent("test", { title: { type: "text", pos: 0 } }); - const remoteComp = { - ...makeComponent("test", { title: { type: "text", pos: 0 } }), - description: "", - }; - - const local: SchemaData = { components: [localComp], folders: [], datasources: [] }; - const remote: RemoteSchemaData = { - components: new Map([["test", remoteComp]]), - componentFolders: new Map(), - datasources: new Map(), - }; - - const result = diffSchema(local, remote); - - expect(result.unchanged).toBe(1); - expect(result.updates).toBe(0); - }); - - it("should show diff when local removes a remote description", () => { - const localComp = makeComponent("test", { title: { type: "text", pos: 0 } }); - const remoteComp = { - ...makeComponent("test", { title: { type: "text", pos: 0 } }), - description: "A test block", - }; - - const local: SchemaData = { components: [localComp], folders: [], datasources: [] }; - const remote: RemoteSchemaData = { - components: new Map([["test", remoteComp]]), - componentFolders: new Map(), - datasources: new Map(), - }; - - const result = diffSchema(local, remote); - - expect(result.updates).toBe(1); - expect(result.diffs[0].action).toBe("update"); - }); - - it("should treat datasource without dimensions as unchanged when remote has empty dimensions", () => { - const local: SchemaData = { - components: [], - folders: [], - datasources: [makeDatasource("Colors", "colors")], - }; - const remote: RemoteSchemaData = { - components: new Map(), - componentFolders: new Map(), - datasources: new Map([ - ["Colors", { ...makeDatasource("Colors", "colors"), dimensions: [] } as any], - ]), - }; - - const result = diffSchema(local, remote); - - expect(result.unchanged).toBe(1); - expect(result.updates).toBe(0); - expect(result.diffs[0].action).toBe("unchanged"); - }); - - it("should not diff component_group_uuid when local does not opt into the escape hatch", () => { - const localComp = makeComponent("page", { title: { type: "text", pos: 0 } }); - // Remote block belongs to a UI-managed group; local leaves it unset. - const remoteComp = { - ...makeComponent("page", { title: { type: "text", pos: 0 } }), - component_group_uuid: "group-uuid", - }; - - const local: SchemaData = { components: [localComp], folders: [], datasources: [] }; - const remote: RemoteSchemaData = { - components: new Map([["page", remoteComp]]), - componentFolders: new Map(), - datasources: new Map(), - }; - - const result = diffSchema(local, remote); - - expect(result.unchanged).toBe(1); - expect(result.updates).toBe(0); - }); - - it("should diff component_group_uuid when local sets it (group escape hatch)", () => { - const localComp = { - ...makeComponent("page", { title: { type: "text", pos: 0 } }), - component_group_uuid: "new-group", - }; - const remoteComp = { - ...makeComponent("page", { title: { type: "text", pos: 0 } }), - component_group_uuid: "old-group", - }; - - const local: SchemaData = { components: [localComp], folders: [], datasources: [] }; - const remote: RemoteSchemaData = { - components: new Map([["page", remoteComp]]), - componentFolders: new Map(), - datasources: new Map(), - }; - - const result = diffSchema(local, remote); - - expect(result.updates).toBe(1); - expect(result.diffs[0].action).toBe("update"); - expect(result.diffs[0].diff).toContain("component_group_uuid"); - }); - - it("should handle all entity types together (component groups are not diffed)", () => { - const local: SchemaData = { - components: [makeComponent("page", {})], - folders: [], - datasources: [makeDatasource("Colors", "colors")], - }; - const remote: RemoteSchemaData = { - components: new Map(), - componentFolders: new Map(), - datasources: new Map(), - }; - - const result = diffSchema(local, remote); - - expect(result.creates).toBe(2); - expect(result.diffs).toHaveLength(2); - }); - - const remoteFolders = ( - folders: Array<{ uuid: string; name: string; parent_uuid?: string | null }>, - ) => - new Map( - folders.map((f) => [f.name, { id: 1, parent_id: null, parent_uuid: null, ...f } as any]), - ); - - it("should emit create diffs for local folders missing remotely", () => { - const result = diffSchema( - { - components: [], - datasources: [], - folders: [{ name: "Layout", path: "layout", parentPath: null }], - }, - { components: new Map(), datasources: new Map(), componentFolders: new Map() }, - ); - expect(result.diffs).toContainEqual( - expect.objectContaining({ type: "folder", name: "layout", action: "create" }), - ); - }); - - it("should emit stale diffs for remote-only folders", () => { - const result = diffSchema( - { components: [], datasources: [], folders: [] }, - { - components: new Map(), - datasources: new Map(), - componentFolders: remoteFolders([{ uuid: "u1", name: "Old" }]), - }, - ); - expect(result.diffs).toContainEqual( - expect.objectContaining({ type: "folder", name: "old", action: "stale" }), - ); - }); - - it("should match folders case-insensitively via slug paths", () => { - const result = diffSchema( - { - components: [], - datasources: [], - folders: [{ name: "layout", path: "layout", parentPath: null }], - }, - { - components: new Map(), - datasources: new Map(), - componentFolders: remoteFolders([{ uuid: "u1", name: "Layout" }]), - }, - ); - expect(result.diffs).toContainEqual( - expect.objectContaining({ type: "folder", name: "layout", action: "unchanged" }), - ); - }); - - it("should diff component group membership in path space", () => { - const local: SchemaData = { - components: [{ ...makeComponent("hero", {}), folder: "layout" }], - datasources: [], - folders: [{ name: "Layout", path: "layout", parentPath: null }], - }; - const remoteComp = { ...makeComponent("hero", {}), component_group_uuid: "u-other" }; - const remote: RemoteSchemaData = { - components: new Map([["hero", remoteComp]]), - datasources: new Map(), - componentFolders: remoteFolders([ - { uuid: "u1", name: "Layout" }, - { uuid: "u-other", name: "Other" }, - ]), - }; - const diff = diffSchema(local, remote).diffs.find( - (d) => d.type === "component" && d.name === "hero", - ); - expect(diff?.action).toBe("update"); - }); - - it("should not diff groups for blocks without a folder key", () => { - const local: SchemaData = { - components: [makeComponent("hero", {})], - datasources: [], - folders: [], - }; - const remoteComp = { ...makeComponent("hero", {}), component_group_uuid: "u-layout" }; - const remote: RemoteSchemaData = { - components: new Map([["hero", remoteComp]]), - datasources: new Map(), - componentFolders: remoteFolders([{ uuid: "u-layout", name: "Layout" }]), - }; - const diff = diffSchema(local, remote).diffs.find( - (d) => d.type === "component" && d.name === "hero", - ); - expect(diff?.action).toBe("unchanged"); - }); - - it("should translate remote component_group_whitelist uuids to paths for diffing", () => { - const local: SchemaData = { - components: [ - makeComponent("page", { - body: { - type: "bloks", - pos: 0, - restrict_components: true, - component_group_whitelist: ["layout"], - }, - }), - ], - datasources: [], - folders: [{ name: "Layout", path: "layout", parentPath: null }], - }; - const remoteComp = makeComponent("page", { - body: { type: "bloks", pos: 0, restrict_components: true, component_group_whitelist: ["u1"] }, - }); - const remote: RemoteSchemaData = { - components: new Map([["page", remoteComp]]), - datasources: new Map(), - componentFolders: remoteFolders([{ uuid: "u1", name: "Layout" }]), - }; - const diff = diffSchema(local, remote).diffs.find( - (d) => d.type === "component" && d.name === "page", - ); - expect(diff?.action).toBe("unchanged"); - }); - - it("should treat a local uuid whitelist as unchanged against the same remote uuid (schema init passthrough)", () => { - // `schema init` emits raw uuid `component_group_whitelist` entries; both - // sides must translate uuid → slug path so the whitelist does not diff forever. - const local: SchemaData = { - components: [ - makeComponent("page", { - body: { - type: "bloks", - pos: 0, - restrict_components: true, - component_group_whitelist: ["u1"], - }, - }), - ], - datasources: [], - folders: [{ name: "Layout", path: "layout", parentPath: null }], - }; - const remoteComp = makeComponent("page", { - body: { type: "bloks", pos: 0, restrict_components: true, component_group_whitelist: ["u1"] }, - }); - const remote: RemoteSchemaData = { - components: new Map([["page", remoteComp]]), - datasources: new Map(), - componentFolders: remoteFolders([{ uuid: "u1", name: "Layout" }]), - }; - const diff = diffSchema(local, remote).diffs.find( - (d) => d.type === "component" && d.name === "page", - ); - expect(diff?.action).toBe("unchanged"); - }); - - it("should not mutate remote component objects across repeated diffs", () => { - const local: SchemaData = { - components: [{ ...makeComponent("hero", {}), folder: "layout" }], - datasources: [], - folders: [{ name: "Layout", path: "layout", parentPath: null }], - }; - const remoteComp = { - ...makeComponent("hero", { - body: { - type: "bloks", - pos: 0, - restrict_components: true, - component_group_whitelist: ["u1"], - }, - }), - component_group_uuid: "u1", - }; - const remote: RemoteSchemaData = { - components: new Map([["hero", remoteComp]]), - datasources: new Map(), - componentFolders: remoteFolders([{ uuid: "u1", name: "Layout" }]), - }; - const first = diffSchema(local, remote).diffs.find( - (d) => d.type === "component" && d.name === "hero", - ); - const snapshot = JSON.stringify(remoteComp); - const second = diffSchema(local, remote).diffs.find( - (d) => d.type === "component" && d.name === "hero", - ); - expect(JSON.stringify(remoteComp)).toBe(snapshot); - expect(first?.action).toBe(second?.action); - }); -}); diff --git a/packages/cli/src/commands/schema/push/diff-schema.ts b/packages/cli/src/commands/schema/push/diff-schema.ts deleted file mode 100644 index 45c958aad..000000000 --- a/packages/cli/src/commands/schema/push/diff-schema.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { createTwoFilesPatch } from "diff"; - -import type { DiffResult, EntityDiff, RemoteSchemaData, SchemaData } from "../types"; -import { applyDefaults, COMPONENT_DEFAULTS, DATASOURCE_DEFAULTS, isRecord } from "../utils"; -import { serializeComponent, serializeDatasource } from "../serialize"; -import { buildGroupPathByUuid } from "../folders"; - -type EntityType = "component" | "datasource"; - -/** - * Deep-copies a component's `schema`, translating each field's - * `component_group_whitelist` uuid entries to slug paths so both sides diff in - * the same slug-path space. Applied symmetrically to remote (whose whitelist is - * always uuids) and local (which may carry raw uuids when produced by - * `schema init`; slug-path entries are not uuid keys in the map and pass - * through unchanged). Unknown uuids are left as-is so they still produce a - * visible diff. The source schema objects are never mutated. - */ -function translateGroupWhitelist(schema: unknown, uuidToPath: Map): unknown { - if (!isRecord(schema)) { - return schema; - } - const result: Record = {}; - for (const [fieldName, field] of Object.entries(schema)) { - if (isRecord(field) && Array.isArray(field.component_group_whitelist)) { - result[fieldName] = { - ...field, - component_group_whitelist: field.component_group_whitelist.map((entry: unknown) => - typeof entry === "string" ? (uuidToPath.get(entry) ?? entry) : entry, - ), - }; - } else { - result[fieldName] = field; - } - } - return result; -} - -function diffEntity( - type: EntityType, - name: string, - localSerialized: string | null, - remoteSerialized: string | null, -): EntityDiff { - if (!remoteSerialized && localSerialized) { - return { type, name, action: "create", diff: null, local: null, remote: null }; - } - if (remoteSerialized && !localSerialized) { - return { type, name, action: "stale", diff: null, local: null, remote: null }; - } - if (localSerialized === remoteSerialized) { - return { type, name, action: "unchanged", diff: null, local: null, remote: null }; - } - - const patch = createTwoFilesPatch( - `remote/${name}`, - `local/${name}`, - remoteSerialized!, - localSerialized!, - "remote", - "local", - ); - - return { type, name, action: "update", diff: patch, local: null, remote: null }; -} - -/** Diffs local schema against remote state and returns classified results. */ -export function diffSchema(local: SchemaData, remote: RemoteSchemaData): DiffResult { - const diffs: EntityDiff[] = []; - - // Build remote group path maps once. `buildGroupPathByUuid` returns slugified - // segments per group uuid; join them into the same slug-path identity space - // used by local folders and component `folder` keys. - const groupPathByUuid = buildGroupPathByUuid([...remote.componentFolders.values()]); - const uuidToPath = new Map(); - for (const [uuid, segments] of groupPathByUuid) { - uuidToPath.set(uuid, segments.join("/")); - } - const remoteFolderPaths = new Set(uuidToPath.values()); - - // Diff folders (before components). Renames are unsupported, so a folder is - // only ever `create`/`unchanged`/`stale` — display names matter at creation - // only. `EntityDiff.name` carries the slug path. - const localFolderPaths = new Set(local.folders.map((f) => f.path)); - for (const folder of local.folders) { - const action = remoteFolderPaths.has(folder.path) ? "unchanged" : "create"; - diffs.push({ - type: "folder", - name: folder.path, - action, - diff: null, - local: null, - remote: null, - }); - } - for (const path of remoteFolderPaths) { - if (!localFolderPaths.has(path)) { - diffs.push({ - type: "folder", - name: path, - action: "stale", - diff: null, - local: null, - remote: null, - }); - } - } - - // Diff components - const processedComponentNames = new Set(); - for (const comp of local.components) { - processedComponentNames.add(comp.name); - const remoteComp = remote.components.get(comp.name); - // Only diff the group UUID when the local block opts into the escape hatch; - // otherwise it stays stripped on both sides so remote UI groups are left - // untouched and no false diff is produced. - const includeGroupUuid = typeof comp.component_group_uuid === "string"; - - // Shallow copies so group membership (`folder`) and whitelist path - // translation never mutate the local schema or the remote component map. - const localForDiff: Record = { ...comp }; - const remoteForDiff: Record | undefined = remoteComp - ? { ...remoteComp } - : undefined; - - // Group membership is only diffed when the local block manages it (a - // `folder` key, string path or `null` for explicitly ungrouped). Synthesize - // the remote block's `folder` from its group uuid so both sides diff in - // slug-path space. Unmanaged blocks keep today's behavior: strip `folder` - // from both sides so remote UI groups are left untouched. - if ("folder" in comp) { - if (remoteForDiff) { - const uuid = remoteForDiff.component_group_uuid; - remoteForDiff.folder = - typeof uuid === "string" && uuid ? (uuidToPath.get(uuid) ?? null) : null; - } - } else { - delete localForDiff.folder; - if (remoteForDiff) { - delete remoteForDiff.folder; - } - } - - // Translate whitelist uuids → slug paths on both sides. `schema init` emits - // raw uuid whitelists locally; without translating the local copy too, a - // local uuid vs remote-translated path would diff dirty forever. - localForDiff.schema = translateGroupWhitelist(localForDiff.schema, uuidToPath); - if (remoteForDiff) { - remoteForDiff.schema = translateGroupWhitelist(remoteForDiff.schema, uuidToPath); - } - - const localSerialized = serializeComponent(applyDefaults(localForDiff, COMPONENT_DEFAULTS), { - includeGroupUuid, - }); - const remoteSerialized = remoteForDiff - ? serializeComponent(applyDefaults(remoteForDiff, COMPONENT_DEFAULTS), { includeGroupUuid }) - : null; - diffs.push(diffEntity("component", comp.name, localSerialized, remoteSerialized)); - } - for (const [name] of remote.components) { - if (!processedComponentNames.has(name)) { - diffs.push(diffEntity("component", name, null, "stale")); - } - } - - // Diff datasources - const processedDatasourceNames = new Set(); - for (const ds of local.datasources) { - processedDatasourceNames.add(ds.name); - const remoteDs = remote.datasources.get(ds.name); - const localSerialized = serializeDatasource(applyDefaults(ds, DATASOURCE_DEFAULTS)); - const remoteSerialized = remoteDs - ? serializeDatasource(applyDefaults(remoteDs, DATASOURCE_DEFAULTS)) - : null; - diffs.push(diffEntity("datasource", ds.name, localSerialized, remoteSerialized)); - } - for (const [name] of remote.datasources) { - if (!processedDatasourceNames.has(name)) { - diffs.push(diffEntity("datasource", name, null, "stale")); - } - } - - return { - diffs, - creates: diffs.filter((d) => d.action === "create").length, - updates: diffs.filter((d) => d.action === "update").length, - unchanged: diffs.filter((d) => d.action === "unchanged").length, - stale: diffs.filter((d) => d.action === "stale").length, - }; -} diff --git a/packages/cli/src/commands/schema/push/index.test.ts b/packages/cli/src/commands/schema/push/index.test.ts index ac3526764..b246b17a3 100644 --- a/packages/cli/src/commands/schema/push/index.test.ts +++ b/packages/cli/src/commands/schema/push/index.test.ts @@ -9,12 +9,12 @@ import { schemaCommand } from "../command"; import type { SchemaData } from "../types"; import { DEFAULT_SPACE, getID } from "../../__tests__/helpers"; -import { loadSchema } from "./load-schema"; +import { loadSchema } from "../load-schema"; // loadSchema uses jiti to dynamically import TypeScript entry files at runtime. // jiti cannot resolve @storyblok/schema imports in the test environment, so we // mock this module and provide schema data directly via preconditions. -vi.mock("./load-schema", () => ({ +vi.mock("../load-schema", () => ({ loadSchema: vi.fn(), })); @@ -172,7 +172,7 @@ const preconditions = { // Runs the real classifier so the aborting error is the production one; it // never touches jiti, only the already-loaded module exports below. const { classifyExports } = - await vi.importActual("./load-schema"); + await vi.importActual("../load-schema"); vi.mocked(loadSchema).mockImplementation(async () => classifyExports({ a: { name: "hero", fields: [{ name: "headline", type: "text" }] }, diff --git a/packages/cli/src/commands/schema/push/index.ts b/packages/cli/src/commands/schema/push/index.ts index 0e7fe6b2c..2c34c04e5 100644 --- a/packages/cli/src/commands/schema/push/index.ts +++ b/packages/cli/src/commands/schema/push/index.ts @@ -12,9 +12,9 @@ import { schemaCommand } from "../command"; import { displayPath } from "../utils"; import type { SchemaPushOptions } from "./constants"; import type { SchemaData } from "../types"; -import { loadSchema } from "./load-schema"; -import { diffSchema } from "./diff-schema"; -import { fetchRemoteSchema } from "../actions"; +import { loadSchema } from "../load-schema"; +import { diffSchema } from "../diff-schema"; +import { fetchRemoteSchema, localToNormalized, remoteToNormalized } from "../actions"; import { buildGroupPathByUuid } from "../folders"; import { buildChangesetEntries, executePush, formatDiffOutput } from "./actions"; import { saveChangeset } from "../changeset"; @@ -99,8 +99,13 @@ schemaCommand `Remote: ${remote.components.size} components, ${remote.datasources.size} datasources`, ); - // 3. Diff components, datasources, and folders (component groups) - const diffResult = diffSchema(local, remote); + // 3. Diff components, datasources, and folders (component groups). + // from = remote (base), to = local (target): the diff describes the push. + // The local DSL opts into group-uuid diffing, so a block that sets + // `component_group_uuid` explicitly gets its membership pushed. + const diffResult = diffSchema(remoteToNormalized(remote), localToNormalized(local), { + compareGroupUuid: true, + }); // 5. Display diffs ui.br(); diff --git a/packages/cli/src/commands/schema/push/migrations/analyze.test.ts b/packages/cli/src/commands/schema/push/migrations/analyze.test.ts index ca67f907d..db69c4892 100644 --- a/packages/cli/src/commands/schema/push/migrations/analyze.test.ts +++ b/packages/cli/src/commands/schema/push/migrations/analyze.test.ts @@ -262,9 +262,9 @@ describe("analyzeBreakingChanges", () => { type: "component", name: "hero", action: "update", - diff: "some diff", - local: null, - remote: null, + changes: [], + before: null, + after: null, }, ], creates: 0, @@ -307,9 +307,9 @@ describe("analyzeBreakingChanges", () => { type: "component", name: "hero", action: "create", - diff: null, - local: null, - remote: null, + changes: [], + before: null, + after: null, }, ], creates: 1, @@ -346,9 +346,9 @@ describe("analyzeBreakingChanges", () => { type: "component", name: "hero", action: "update", - diff: "some diff", - local: null, - remote: null, + changes: [], + before: null, + after: null, }, ], creates: 0, @@ -382,9 +382,9 @@ describe("analyzeBreakingChanges", () => { type: "component", name: "hero", action: "update", - diff: "some diff", - local: null, - remote: null, + changes: [], + before: null, + after: null, }, ], creates: 0, @@ -428,9 +428,9 @@ describe("analyzeBreakingChanges", () => { type: "component", name: "hero", action: "update", - diff: "some diff", - local: null, - remote: null, + changes: [], + before: null, + after: null, }, ], creates: 0, diff --git a/packages/cli/src/commands/schema/push/write-local-components.test.ts b/packages/cli/src/commands/schema/push/write-local-components.test.ts index f4f40f381..a99adb213 100644 --- a/packages/cli/src/commands/schema/push/write-local-components.test.ts +++ b/packages/cli/src/commands/schema/push/write-local-components.test.ts @@ -19,9 +19,9 @@ function makeDiff(stale: string[]): DiffResult { type: "component", name, action: "stale", - diff: null, - local: null, - remote: null, + changes: [], + before: null, + after: null, })), creates: 0, updates: 0, diff --git a/packages/cli/src/commands/schema/serialize.ts b/packages/cli/src/commands/schema/serialize.ts index 341ea0642..18fd31bb6 100644 --- a/packages/cli/src/commands/schema/serialize.ts +++ b/packages/cli/src/commands/schema/serialize.ts @@ -44,8 +44,9 @@ function sortSchemaByPos( const FOLDER_UNGROUPED = "__FOLDER_UNGROUPED__"; /** - * Serializes a component to a normalized `defineBlock()` code string. - * Strips API-assigned fields. Uses stable property ordering. + * Returns a component reduced to a normalized, stably-ordered object: API-assigned + * fields stripped, schema fields sorted by `pos`. Shared by + * {@link serializeComponent} and field-level diffing. * * `component_group_uuid` is stripped by default (groups are a UI concern), but * kept when `includeGroupUuid` is set — used by diffing when a block opts into @@ -54,10 +55,10 @@ const FOLDER_UNGROUPED = "__FOLDER_UNGROUPED__"; * The transient `folder` key (slug path, or `null` for explicitly ungrouped) is * emitted when present so group membership diffs in slug-path space. */ -export function serializeComponent( +export function cleanComponent( component: Record, options: { includeGroupUuid?: boolean } = {}, -): string { +): Record { const stripSet = options.includeGroupUuid ? new Set([...COMPONENT_STRIP_KEYS].filter((key) => key !== "component_group_uuid")) : COMPONENT_STRIP_KEYS; @@ -98,16 +99,24 @@ export function serializeComponent( ordered.schema = clean.schema; } - return `defineBlock(${formatValue(ordered, 0)})`.replace( + return ordered; +} + +export function serializeComponent( + component: Record, + options: { includeGroupUuid?: boolean } = {}, +): string { + return `defineBlock(${formatValue(cleanComponent(component, options), 0)})`.replace( `folder: '${FOLDER_UNGROUPED}'`, "folder: null", ); } /** - * Serializes a datasource to a normalized `defineDatasource()` code string. + * Returns a datasource reduced to a normalized, stably-ordered object (API-assigned + * keys stripped). Shared by {@link serializeDatasource} and field-level diffing. */ -export function serializeDatasource(datasource: Record): string { +export function cleanDatasource(datasource: Record): Record { const clean = stripKeys(datasource, DATASOURCE_STRIP_KEYS); if (Array.isArray(clean.dimensions)) { @@ -133,5 +142,12 @@ export function serializeDatasource(datasource: Record): string } } - return `defineDatasource(${formatValue(ordered, 0)})`; + return ordered; +} + +/** + * Serializes a datasource to a normalized `defineDatasource()` code string. + */ +export function serializeDatasource(datasource: Record): string { + return `defineDatasource(${formatValue(cleanDatasource(datasource), 0)})`; } diff --git a/packages/cli/src/commands/schema/types.ts b/packages/cli/src/commands/schema/types.ts index 788cca581..3032eab9a 100644 --- a/packages/cli/src/commands/schema/types.ts +++ b/packages/cli/src/commands/schema/types.ts @@ -28,15 +28,30 @@ export interface RemoteSchemaData { datasources: Map; } +/** + * A schema reduced to name-keyed maps — the common shape both a local file and a + * remote space resolve to. `diffSchema` compares two of these regardless of where + * each side came from. + */ +export interface NormalizedSchema { + components: Map; + datasources: Map; + /** Block folders (component groups) keyed by slug path — the folder's identity. */ + folders: Map; +} + export type DiffAction = "create" | "update" | "unchanged" | "stale"; export interface EntityDiff { type: "component" | "datasource" | "folder"; name: string; action: DiffAction; - diff: string | null; - local: Record | null; - remote: Record | null; + /** Field-level changes; populated for `update`, empty for other actions. */ + changes: FieldChange[]; + /** Raw source-side (`from`) entity, or null when the entity is created (target-only). */ + before: Record | null; + /** Raw target-side (`to`) entity, or null when the entity is stale (source-only). */ + after: Record | null; } export interface DiffResult { @@ -50,8 +65,8 @@ export interface DiffResult { export interface FieldChange { field: string; change: "added" | "removed" | "modified"; - before?: Record; - after?: Record; + before?: unknown; + after?: unknown; } export interface ChangesetEntry {