From ffceb86f013404bf872c9aa07e07f091b795ad2f Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Tue, 7 Jul 2026 21:16:53 +0000 Subject: [PATCH] fix: only push and pull models with meaningful changes The Prismic API sometimes returns model properties in a different order than the local files. That is not a real change, but pull, push, and status treated it as one, so users saw models to sync when nothing had really changed. These commands now act only on real changes, like a new field, a reordered field, or a changed value. A difference in property order alone is ignored. Local files also stay stable, so later pulls only show real edits. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/adapters/index.ts | 9 +++--- src/canonicalize.ts | 57 ++++++++++++++++++++++++++++++++++++++ src/commands/pull.ts | 6 ++-- src/commands/push.ts | 6 ++-- src/commands/status.ts | 6 ++-- src/models.ts | 21 +------------- test/status.serial.test.ts | 50 ++++++++++++++++++++++++++++++++- 7 files changed, 121 insertions(+), 34 deletions(-) create mode 100644 src/canonicalize.ts diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 4e3fc7df..9f38ccd3 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url"; import { generateTypes } from "prismic-ts-codegen"; import { glob } from "tinyglobby"; +import { canonicalizeCustomType, canonicalizeSlice } from "../canonicalize"; import { readJsonFile, writeFileRecursive } from "../lib/file"; import { stringify } from "../lib/json"; import { readPackageJson } from "../lib/packageJson"; @@ -110,14 +111,14 @@ export abstract class Adapter { const sliceDirectoryName = pascalCase(model.name); const sliceDirectory = new URL(sliceDirectoryName, appendTrailingSlash(library)); const modelPath = new URL("model.json", appendTrailingSlash(sliceDirectory)); - await writeFileRecursive(modelPath, stringify(model)); + await writeFileRecursive(modelPath, stringify(canonicalizeSlice(model))); await this.createSliceIndexFile(library); await this.onSliceCreated(model, library); } async updateSlice(model: SharedSlice): Promise { const slice = await this.getSlice(model.id); - await writeFileRecursive(slice.modelPath, stringify(model)); + await writeFileRecursive(slice.modelPath, stringify(canonicalizeSlice(model))); await this.onSliceUpdated(model); } @@ -168,14 +169,14 @@ export abstract class Adapter { library ??= await this.getDefaultCustomTypeLibrary(); const customTypeDirectory = new URL(model.id, appendTrailingSlash(library)); const modelPath = new URL("index.json", appendTrailingSlash(customTypeDirectory)); - await writeFileRecursive(modelPath, stringify(model)); + await writeFileRecursive(modelPath, stringify(canonicalizeCustomType(model))); if (model.format === "page") await addRoute(model); await this.onCustomTypeCreated(model); } async updateCustomType(model: CustomType): Promise { const customType = await this.getCustomType(model.id); - await writeFileRecursive(customType.modelPath, stringify(model)); + await writeFileRecursive(customType.modelPath, stringify(canonicalizeCustomType(model))); await updateRoute(model); await this.onCustomTypeUpdated(model); } diff --git a/src/canonicalize.ts b/src/canonicalize.ts new file mode 100644 index 00000000..e24e7755 --- /dev/null +++ b/src/canonicalize.ts @@ -0,0 +1,57 @@ +import type { CustomType, DynamicWidget, SharedSlice } from "@prismicio/types-internal/lib/customtypes"; + +type Field = DynamicWidget; +type Fields = Record; +type Variation = SharedSlice["variations"][number]; + +/** + * Returns a copy of a model with object keys sorted alphabetically, so models + * stay stable on disk and compare equal regardless of the key order the Prismic + * API returns them in. + * + * Only keys whose order has no meaning are sorted: model metadata, field + * definitions, and field config. The order of tabs, and of fields within a tab, + * a group, or a slice variation, is preserved because that order is meaningful. + */ +export function canonicalizeCustomType(model: CustomType): CustomType { + return { ...sortKeys(model), json: mapValues(model.json, canonicalizeFields) }; +} + +export function canonicalizeSlice(model: SharedSlice): SharedSlice { + return { ...sortKeys(model), variations: model.variations.map(canonicalizeVariation) }; +} + +function canonicalizeVariation(variation: Variation): Variation { + const sorted = sortKeys(variation); + if (sorted.primary) sorted.primary = canonicalizeFields(sorted.primary); + if (sorted.items) sorted.items = canonicalizeFields(sorted.items); + return sorted; +} + +function canonicalizeFields(fields: Record): Record { + return mapValues(fields, canonicalizeField); +} + +function canonicalizeField(field: F): F { + const sorted = sortKeys(field); + if ("config" in sorted && sorted.config) sorted.config = canonicalizeConfig(sorted.config); + return sorted; +} + +function canonicalizeConfig>(config: C): C { + const sorted = sortKeys(config); + // Only group fields nest a field map under `config.fields`; preserve its order. + const group = sorted as { fields?: Fields }; + if (group.fields) group.fields = canonicalizeFields(group.fields); + return sorted; +} + +function sortKeys(object: T): T { + return Object.fromEntries( + Object.entries(object as Record).sort(([a], [b]) => a.localeCompare(b)), + ) as T; +} + +function mapValues(record: Record, fn: (value: T) => T): Record { + return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, fn(value)])); +} diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 866609b1..f7bf0af5 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -1,5 +1,6 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; +import { canonicalizeCustomType, canonicalizeSlice } from "../canonicalize"; import { getCustomTypes, getSlices } from "../clients/custom-types"; import { completeOnboardingStepsSilently } from "../clients/repository"; import { resolveEnvironment } from "../environments"; @@ -7,7 +8,6 @@ import { CommandError, createCommand, type CommandConfig } from "../lib/command" import { diffArrays } from "../lib/diff"; import { getDirtyPaths, getGitRoot } from "../lib/git"; import { isDescendant, relativePathname } from "../lib/url"; -import { canonicalizeModel } from "../models"; import { findProjectRoot, getRepositoryName } from "../project"; const config = { @@ -83,7 +83,7 @@ export default createCommand(config, async ({ values }) => { { getKey: (model) => model.id, equals: (a, b) => - JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)), + JSON.stringify(canonicalizeCustomType(a)) === JSON.stringify(canonicalizeCustomType(b)), }, ); const sliceOps = diffArrays( @@ -92,7 +92,7 @@ export default createCommand(config, async ({ values }) => { { getKey: (model) => model.id, equals: (a, b) => - JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)), + JSON.stringify(canonicalizeSlice(a)) === JSON.stringify(canonicalizeSlice(b)), }, ); diff --git a/src/commands/push.ts b/src/commands/push.ts index 1d204f50..6d57fa9f 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -4,6 +4,7 @@ import type { CustomType } from "@prismicio/types-internal/lib/customtypes"; import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; +import { canonicalizeCustomType, canonicalizeSlice } from "../canonicalize"; import { getDocumentTotalByCustomTypes } from "../clients/core"; import { deleteScreenshots, @@ -27,7 +28,6 @@ import { diffArrays } from "../lib/diff"; import { getDirtyPaths, getGitRoot } from "../lib/git"; import { BadRequestError } from "../lib/request"; import { appendTrailingSlash, isDescendant, relativePathname } from "../lib/url"; -import { canonicalizeModel } from "../models"; import { findProjectRoot, getRepositoryName } from "../project"; const config = { @@ -100,7 +100,7 @@ export default createCommand(config, async ({ values }) => { { getKey: (model) => model.id, equals: (a, b) => - JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)), + JSON.stringify(canonicalizeCustomType(a)) === JSON.stringify(canonicalizeCustomType(b)), }, ); const sliceOps = diffArrays( @@ -109,7 +109,7 @@ export default createCommand(config, async ({ values }) => { { getKey: (model) => model.id, equals: (a, b) => - JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)), + JSON.stringify(canonicalizeSlice(a)) === JSON.stringify(canonicalizeSlice(b)), }, ); diff --git a/src/commands/status.ts b/src/commands/status.ts index f148559f..fe5a3de2 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -2,6 +2,7 @@ import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/cust import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; +import { canonicalizeCustomType, canonicalizeSlice } from "../canonicalize"; import { getCustomTypes, getSlices } from "../clients/custom-types"; import { getProfile } from "../clients/user"; import { resolveEnvironment } from "../environments"; @@ -10,7 +11,6 @@ import { diffArrays, type ArrayDiff } from "../lib/diff"; import { getDirtyPaths, getGitRoot } from "../lib/git"; import { dedent } from "../lib/string"; import { isDescendant, relativePathname } from "../lib/url"; -import { canonicalizeModel } from "../models"; import { findProjectRoot, getRepositoryName } from "../project"; const config = { @@ -64,7 +64,7 @@ export default createCommand(config, async ({ values }) => { { getKey: (m) => m.id, equals: (a, b) => - JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)), + JSON.stringify(canonicalizeCustomType(a)) === JSON.stringify(canonicalizeCustomType(b)), }, ); sliceOps = diffArrays( @@ -73,7 +73,7 @@ export default createCommand(config, async ({ values }) => { { getKey: (m) => m.id, equals: (a, b) => - JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)), + JSON.stringify(canonicalizeSlice(a)) === JSON.stringify(canonicalizeSlice(b)), }, ); } diff --git a/src/models.ts b/src/models.ts index 525409cc..7dcddde4 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,9 +1,4 @@ -import type { - CustomType, - DynamicWidget, - Link, - SharedSlice, -} from "@prismicio/types-internal/lib/customtypes"; +import type { DynamicWidget, Link } from "@prismicio/types-internal/lib/customtypes"; import type { Adapter } from "./adapters"; import type { CommandConfig } from "./lib/command"; @@ -11,20 +6,6 @@ import type { CommandConfig } from "./lib/command"; import { getAdapter } from "./adapters"; import { CommandError } from "./lib/command"; -export function canonicalizeModel(model: T): T { - const canonicalizedModel = sortKeys(model); - if ("variations" in canonicalizedModel) { - canonicalizedModel.variations = canonicalizedModel.variations.map((variation) => - sortKeys(variation), - ); - } - return canonicalizedModel; -} - -function sortKeys>(obj: T): T { - return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))) as T; -} - type Field = DynamicWidget; type Fields = Record; type ModelKind = "slice" | "customType"; diff --git a/test/status.serial.test.ts b/test/status.serial.test.ts index dd5c4668..876b8a37 100644 --- a/test/status.serial.test.ts +++ b/test/status.serial.test.ts @@ -1,4 +1,4 @@ -import { buildCustomType, buildSlice, it, writeLocalCustomType } from "./it"; +import { buildCustomType, buildSlice, it, readLocalCustomType, writeLocalCustomType } from "./it"; import { insertCustomType, insertSlice } from "./prismic"; it("supports --help", async ({ expect, prismic }) => { @@ -71,6 +71,54 @@ it("warns and skips --env when not logged in", async ({ expect, prismic, logout, expect(stdout).toContain("Environment: anything"); }); +it("reports in-sync when local only reorders metadata and config keys", async ({ + expect, + project, + prismic, + repo, + token, + host, +}) => { + // A field with multiple config keys, so config key order can be reordered. + const customType = buildCustomType({ + json: { + Main: { + title: { type: "Text", config: { label: "Title", placeholder: "Enter a title" } }, + }, + }, + } as Partial>); + await insertCustomType(customType, { repo, token, host }); + + // Pull writes the canonical form to disk. + const pull = await prismic("pull", ["--repo", repo]); + expect(pull.exitCode).toBe(0); + + // Hand-edit the local file: reverse the order of metadata keys and of each + // field's config keys, leaving all values and the field order unchanged. + const pulled = await readLocalCustomType(project, customType.id); + // Pull writes the canonical (sorted-key) form, not the raw API key order. + expect(Object.keys(pulled)).toEqual(Object.keys(pulled).sort()); + const canonical = JSON.stringify(pulled, null, 2); + for (const fields of Object.values(pulled.json)) { + for (const field of Object.values(fields)) { + const f = field as { config?: Record }; + if (f.config) { + expect(Object.keys(f.config)).toEqual(Object.keys(f.config).sort()); + f.config = Object.fromEntries(Object.entries(f.config).reverse()); + } + } + } + const scrambled = Object.fromEntries(Object.entries(pulled).reverse()) as typeof pulled; + // Confirm the hand-edit really produced a non-canonical file. + expect(JSON.stringify(scrambled, null, 2)).not.toBe(canonical); + await writeLocalCustomType(project, scrambled); + + // Both sides canonicalize equal, so status must report no changes. + const { stdout, exitCode } = await prismic("status", ["--repo", repo]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Already up to date."); +}); + it("reports differing models when local and remote disagree", async ({ expect, project,