Skip to content

Commit cc5ac22

Browse files
authored
fix(wrangler): fix spurious config diffs for binding arrays in different order (#12135)
1 parent b900c5a commit cc5ac22

File tree

3 files changed

+277
-6
lines changed

3 files changed

+277
-6
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
Fix spurious config diffs when bindings from local and remote config are shown in different order
6+
7+
When comparing local and remote Worker configurations, binding arrays like `kv_namespaces` would incorrectly show additions and removals if the elements were in a different order. The diff now correctly recognizes these as equivalent by reordering remote arrays to match the local config's order before comparison.

packages/wrangler/src/__tests__/deploy/get-remote-config-diff.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,4 +682,99 @@ describe("getRemoteConfigsDiff", () => {
682682
`);
683683
});
684684
});
685+
686+
it("should not show spurious diffs when binding arrays have same elements in different order", ({
687+
expect,
688+
}) => {
689+
// This test verifies that:
690+
// - Same bindings with different array/property order produce no diff
691+
// - Actual binding differences are still shown correctly
692+
// - Both non-nested (kv_namespaces) and nested (durable_objects.bindings) arrays work
693+
const { diff, nonDestructive } = getRemoteConfigDiff(
694+
{
695+
name: "my-worker",
696+
main: "/tmp/src/index.js",
697+
workers_dev: true,
698+
preview_urls: true,
699+
// Non-nested binding with different array/property order AND actual difference
700+
// KV_A and KV_B are same (different order), KV_C is remote-only
701+
kv_namespaces: [
702+
{ id: "id-1", binding: "KV_A" },
703+
{ id: "id-2", binding: "KV_B" },
704+
{ id: "id-3", binding: "KV_C" },
705+
],
706+
// Same elements, different order - should not appear in diff
707+
queues: {
708+
producers: [
709+
{ binding: "QUEUE_A", queue: "queue-a" },
710+
{ binding: "QUEUE_B", queue: "queue-b" },
711+
],
712+
},
713+
// Nested binding with actual difference - DO_C is remote-only
714+
durable_objects: {
715+
bindings: [
716+
{ name: "DO_A", class_name: "DurableObjectA" },
717+
{ name: "DO_C", class_name: "DurableObjectC" },
718+
],
719+
},
720+
},
721+
{
722+
name: "my-worker",
723+
main: "/tmp/src/index.js",
724+
// Local has different array order and { binding, id } property order
725+
// KV_A and KV_B are same (different order), KV_D is local-only
726+
kv_namespaces: [
727+
{ binding: "KV_B", id: "id-2" },
728+
{ binding: "KV_A", id: "id-1" },
729+
{ binding: "KV_D", id: "id-4" },
730+
],
731+
queues: {
732+
producers: [
733+
{ binding: "QUEUE_B", queue: "queue-b" },
734+
{ binding: "QUEUE_A", queue: "queue-a" },
735+
],
736+
},
737+
// DO_B is local-only
738+
durable_objects: {
739+
bindings: [
740+
{ name: "DO_B", class_name: "DurableObjectB" },
741+
{ name: "DO_A", class_name: "DurableObjectA" },
742+
],
743+
},
744+
} as unknown as Config
745+
);
746+
747+
// kv_namespaces and durable_objects show actual diffs
748+
// queues.producers has no diff (same elements, different order)
749+
assert(diff);
750+
expect(normalizeDiff(diff.toString())).toMatchInlineSnapshot(`
751+
" {
752+
kv_namespaces: [
753+
...
754+
...
755+
{
756+
- id: \\"id-3\\"
757+
+ id: \\"id-4\\"
758+
- binding: \\"KV_C\\"
759+
+ binding: \\"KV_D\\"
760+
}
761+
]
762+
durable_objects: {
763+
bindings: [
764+
+ {
765+
+ name: \\"DO_B\\"
766+
+ class_name: \\"DurableObjectB\\"
767+
+ }
768+
...
769+
- {
770+
- name: \\"DO_C\\"
771+
- class_name: \\"DurableObjectC\\"
772+
- }
773+
]
774+
}
775+
}
776+
"
777+
`);
778+
expect(nonDestructive).toBe(false);
779+
});
685780
});

packages/wrangler/src/deploy/config-diffs.ts

Lines changed: 175 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,57 @@ import {
66
isNonDestructive,
77
} from "../utils/diff-json";
88
import type { JsonLike } from "../utils/diff-json";
9-
import type { Config, RawConfig } from "@cloudflare/workers-utils";
9+
import type {
10+
CfWorkerInit,
11+
Config,
12+
RawConfig,
13+
} from "@cloudflare/workers-utils";
14+
15+
// Exhaustive map of all binding keys in CfWorkerInit["bindings"].
16+
// When a new binding type is added, TypeScript will error here until it is handled.
17+
const reorderableBindings = {
18+
// Top-level binding arrays
19+
kv_namespaces: true,
20+
r2_buckets: true,
21+
d1_databases: true,
22+
services: true,
23+
send_email: true,
24+
vectorize: true,
25+
hyperdrive: true,
26+
workflows: true,
27+
dispatch_namespaces: true,
28+
mtls_certificates: true,
29+
pipelines: true,
30+
secrets_store_secrets: true,
31+
ratelimits: true,
32+
analytics_engine_datasets: true,
33+
unsafe_hello_world: true,
34+
worker_loaders: true,
35+
vpc_services: true,
36+
37+
// Wrapper objects containing binding arrays
38+
durable_objects: true,
39+
queues: true,
40+
logfwdr: true,
41+
42+
// Non-array bindings (nothing to reorder)
43+
vars: false,
44+
wasm_modules: false,
45+
text_blobs: false,
46+
data_blobs: false,
47+
browser: false,
48+
ai: false,
49+
images: false,
50+
media: false,
51+
version_metadata: false,
52+
unsafe: false,
53+
assets: false,
54+
} satisfies Record<keyof CfWorkerInit["bindings"], boolean>;
55+
56+
/** Extracts the keys of T whose values are `true` */
57+
type ReorderableKeys<T extends Record<string, boolean>> = {
58+
[K in keyof T]: T[K] extends true ? K : never;
59+
}[keyof T];
1060

1161
/**
1262
* Object representing the difference of two configuration objects.
@@ -37,7 +87,7 @@ export function getRemoteConfigDiff(
3787
normalizeLocalResolvedConfigAsRemote(localResolvedConfig);
3888
const normalizedRemoteConfig = normalizeRemoteConfigAsResolvedLocal(
3989
remoteConfig,
40-
localResolvedConfig
90+
normalizedLocalConfig
4191
);
4292

4393
const diff = diffJsonObjects(
@@ -245,19 +295,19 @@ function normalizeObservability(
245295
* - removing from the remote config all the default values that in the local config are either not present or undefined
246296
*
247297
* @param remoteConfig The remote config object to normalize
248-
* @param localResolvedConfig The target/local (resolved) config object
298+
* @param localConfig The target/local (resolved) config object
249299
* @returns The remote config object normalized and ready to be compared with the local one
250300
*/
251301
function normalizeRemoteConfigAsResolvedLocal(
252302
remoteConfig: RawConfig,
253-
localResolvedConfig: Config
303+
localConfig: Config
254304
): Config {
255305
let normalizedRemote = {} as Config;
256306

257307
// We start by adding all the local configs to the normalized remote config object
258308
// in this way we can make sure that local-only configurations are not shown as
259309
// differences between local and remote configs
260-
Object.entries(localResolvedConfig).forEach(([key, value]) => {
310+
Object.entries(localConfig).forEach(([key, value]) => {
261311
if (
262312
// We want to skip observability since it has a remote default behavior
263313
// different from that of wrangler
@@ -287,12 +337,131 @@ function normalizeRemoteConfigAsResolvedLocal(
287337
// the configuration options in the same order as their config file)
288338
normalizedRemote = orderObjectFields(
289339
normalizedRemote as unknown as Record<string, unknown>,
290-
localResolvedConfig as unknown as Record<string, unknown>
340+
localConfig as unknown as Record<string, unknown>
291341
) as unknown as Config;
292342

343+
// Reorder binding arrays to match local's order so the diff is intuitive.
344+
// Binding array order doesn't matter semantically, but positional diffing
345+
// would show spurious changes if the same elements appear in different order.
346+
for (const [bindingKey, shouldReorder] of Object.entries(
347+
reorderableBindings
348+
)) {
349+
if (!shouldReorder) {
350+
continue;
351+
}
352+
353+
const key = bindingKey as ReorderableKeys<typeof reorderableBindings>;
354+
355+
// Handle wrapper objects that contain binding arrays as nested properties
356+
if (key === "queues") {
357+
// Only producers are bindings (accessible from Worker code).
358+
// Consumers configure message delivery to the Worker and are
359+
// managed through the Queues API, not the Worker bindings API,
360+
// so they don't appear in the remote config.
361+
if (normalizedRemote.queues?.producers && localConfig.queues?.producers) {
362+
normalizedRemote.queues.producers = reorderBindings(
363+
normalizedRemote.queues.producers,
364+
localConfig.queues.producers
365+
);
366+
}
367+
continue;
368+
}
369+
370+
if (key === "durable_objects") {
371+
if (
372+
normalizedRemote.durable_objects?.bindings &&
373+
localConfig.durable_objects?.bindings
374+
) {
375+
normalizedRemote.durable_objects.bindings = reorderBindings(
376+
normalizedRemote.durable_objects.bindings,
377+
localConfig.durable_objects.bindings
378+
);
379+
}
380+
continue;
381+
}
382+
383+
if (key === "logfwdr") {
384+
if (normalizedRemote.logfwdr?.bindings && localConfig.logfwdr?.bindings) {
385+
normalizedRemote.logfwdr.bindings = reorderBindings(
386+
normalizedRemote.logfwdr.bindings,
387+
localConfig.logfwdr.bindings
388+
);
389+
}
390+
continue;
391+
}
392+
393+
// Top-level binding arrays
394+
reorderConfigBindings(normalizedRemote, localConfig, key);
395+
}
396+
293397
return normalizedRemote;
294398
}
295399

400+
/**
401+
* Generates a stable key for a binding object by JSON-serializing it with sorted keys,
402+
* so that objects with the same properties in different order produce the same key.
403+
*/
404+
function getBindingKey(obj: unknown): string {
405+
return JSON.stringify(obj, (_, v) =>
406+
v && typeof v === "object" && !Array.isArray(v)
407+
? Object.fromEntries(
408+
Object.keys(v)
409+
.sort()
410+
.map((k) => [k, v[k]])
411+
)
412+
: v
413+
);
414+
}
415+
416+
/**
417+
* Reorders a remote binding array to match the local array's order.
418+
* Elements present in both arrays are placed first (in local order),
419+
* followed by elements only in the remote array.
420+
*
421+
* @example
422+
* ```ts
423+
* reorderBindings(
424+
* [{ binding: "A" }, { binding: "B" }, { binding: "C" }], // remote
425+
* [{ binding: "C" }, { binding: "A" }, { binding: "D" }] // local
426+
* )
427+
* // => [{ binding: "C" }, { binding: "A" }, { binding: "B" }]
428+
* // matched C and A are placed in local order, then unmatched B is appended
429+
* ```
430+
*/
431+
function reorderBindings<T>(remote: T[], local: T[]): T[] {
432+
const remoteByKey = new Map(remote.map((el) => [getBindingKey(el), el]));
433+
const used = new Set<string>();
434+
const result: T[] = [];
435+
for (const binding of local) {
436+
const key = getBindingKey(binding);
437+
const remoteEl = remoteByKey.get(key);
438+
if (remoteEl !== undefined) {
439+
result.push(remoteEl);
440+
used.add(key);
441+
}
442+
}
443+
for (const binding of remote) {
444+
if (!used.has(getBindingKey(binding))) {
445+
result.push(binding);
446+
}
447+
}
448+
return result;
449+
}
450+
451+
/**
452+
* Reorders a top-level binding array on the remote config to match the local config's order.
453+
* Uses a generic key parameter so TypeScript can correlate the types of both accesses.
454+
*/
455+
function reorderConfigBindings<
456+
K extends ReorderableKeys<typeof reorderableBindings>,
457+
>(normalizedRemote: Config, localConfig: Config, key: K): void {
458+
const remoteArr = normalizedRemote[key];
459+
const localArr = localConfig[key];
460+
if (Array.isArray(remoteArr) && Array.isArray(localArr)) {
461+
normalizedRemote[key] = reorderBindings(remoteArr, localArr) as Config[K];
462+
}
463+
}
464+
296465
/**
297466
* This function reorders the fields of a given object so that they follow a given target object.
298467
* All the fields of the given object not present in the target object will be ordered last.

0 commit comments

Comments
 (0)