From 774ad40a7134b43e3aa03bfe8a1685a418efecd6 Mon Sep 17 00:00:00 2001 From: Gregory Collett Date: Thu, 30 Jul 2026 11:18:10 +0100 Subject: [PATCH] Create the local persisted queries file when it is missing With `persistConfig.file`, the compiler previously refused to start if the file did not exist ("The file `...` for the local query persisting does not exist"), even though the file is a compiler output. On fresh checkouts and in CI the file never exists, forcing projects to commit a stub map or wrap the compiler in a script that pre-creates one. - PersistConfig deserialization no longer requires the file to exist (this also removes filesystem access from Deserialize, which resolved the path against the process cwd rather than the config location). - LocalPersister::new treats a missing file as an empty query map; other read errors now surface the underlying io error. - LocalPersister::finalize creates missing parent directories, the same way the artifact writer does for artifacts. Co-Authored-By: Claude Fable 5 --- .../relay-compiler-config-schema.json | 2 +- .../operation_persister/local_persister.rs | 18 ++- ...queries_file_created_when_missing.expected | 113 +++++++++++++++++ ...ed_queries_file_created_when_missing.input | 33 +++++ ...s_file_existing_entries_preserved.expected | 119 ++++++++++++++++++ ...ries_file_existing_entries_preserved.input | 38 ++++++ ...issing_parent_directories_created.expected | 113 +++++++++++++++++ ...e_missing_parent_directories_created.input | 33 +++++ .../tests/relay_compiler_integration_test.rs | 23 +++- .../crates/relay-config/src/project_config.rs | 12 +- 10 files changed, 490 insertions(+), 14 deletions(-) create mode 100644 compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_created_when_missing.expected create mode 100644 compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_created_when_missing.input create mode 100644 compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_existing_entries_preserved.expected create mode 100644 compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_existing_entries_preserved.input create mode 100644 compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_missing_parent_directories_created.expected create mode 100644 compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_missing_parent_directories_created.input diff --git a/compiler/crates/relay-compiler/relay-compiler-config-schema.json b/compiler/crates/relay-compiler/relay-compiler-config-schema.json index 2edf19d0b7beb..5b6609c1bcacb 100644 --- a/compiler/crates/relay-compiler/relay-compiler-config-schema.json +++ b/compiler/crates/relay-compiler/relay-compiler-config-schema.json @@ -1126,7 +1126,7 @@ "$ref": "#/$defs/RemotePersistConfig" }, { - "description": "This variant represents a local persistence configuration, where GraphQL queries are persisted to a local JSON file.\n\nWhen this variant is used, the compiler will attempt to read the local file as a hash map,\nadd new queries to the map, and then serialize and write the resulting map to the configured path.", + "description": "This variant represents a local persistence configuration, where GraphQL queries are persisted to a local JSON file.\n\nWhen this variant is used, the compiler will attempt to read the local file as a hash map,\nadd new queries to the map, and then serialize and write the resulting map to the configured path.\nThe file (and any missing parent directories) is created if it does not already exist.", "$ref": "#/$defs/LocalPersistConfig" } ] diff --git a/compiler/crates/relay-compiler/src/operation_persister/local_persister.rs b/compiler/crates/relay-compiler/src/operation_persister/local_persister.rs index 84e55c8730404..8cc2704075ef4 100644 --- a/compiler/crates/relay-compiler/src/operation_persister/local_persister.rs +++ b/compiler/crates/relay-compiler/src/operation_persister/local_persister.rs @@ -8,6 +8,7 @@ use std::collections::BTreeMap; use std::fs::File; use std::io::BufWriter; +use std::io::ErrorKind; use std::io::Write; use async_trait::async_trait; @@ -37,10 +38,16 @@ impl LocalPersister { pub fn new(config: LocalPersistConfig) -> Self { let query_map: DashMap = match std::fs::read_to_string(&config.file) { Ok(content) => serde_json::from_str(&content).unwrap_or_default(), - Err(_e) => { + Err(e) if e.kind() == ErrorKind::NotFound => { + // First run: the file doesn't exist yet. Start with an empty + // map; `finalize` creates the file. + Default::default() + } + Err(e) => { panic!( - "LocalPersister: Expected the {} file to exist.", + "LocalPersister: Unable to read the {} file: {}", &config.file.display(), + e, ) } }; @@ -91,6 +98,13 @@ impl OperationPersister for LocalPersister { .map(|x| (x.key().clone(), x.value().clone())) .collect(); + // `Path::parent` returns `Some("")` for a bare relative file name like + // `persisted_queries.json`, and `create_dir_all("")` fails. + if let Some(parent) = self.config.file.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } let mut writer = BufWriter::new(File::create(&self.config.file)?); serde_json::to_writer_pretty(&mut writer, &ordered)?; writer.write_all(b"\n")?; diff --git a/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_created_when_missing.expected b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_created_when_missing.expected new file mode 100644 index 0000000000000..e86c148d2c7e4 --- /dev/null +++ b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_created_when_missing.expected @@ -0,0 +1,113 @@ +==================================== INPUT ==================================== +//- src/component.js + +graphql`query componentQuery { + me { + name + } +}` + +//- relay.config.json +{ + "sources": { + "src": "test_project" + }, + "projects": { + "test_project": { + "schema": "schema.graphql", + "language": "javascript", + "persist": { + "file": "./persisted_queries.json", + "algorithm": "MD5" + } + } + } +} + +//- schema.graphql +type Query { + me: User +} + +type User { + name: String +} +==================================== OUTPUT =================================== +//-++ persisted_queries.json +{ + "a70bad875245c0b80e06c2084c9fbff5": "query componentQuery {\n me {\n name\n }\n}\n" +} + +//-++ src/__generated__/componentQuery.graphql.js +/** + * SignedSource<<5e6e5f0ef8b7ed3b7ff8eabb168ed5a0>> + * @relayHash a70bad875245c0b80e06c2084c9fbff5 + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +// @relayRequestID a70bad875245c0b80e06c2084c9fbff5 + +var node = (function(){ +var v0 = [ + { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "me", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "componentQuery", + "selections": (v0/*:: as any*/), + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "componentQuery", + "selections": (v0/*:: as any*/) + }, + "params": { + "id": "a70bad875245c0b80e06c2084c9fbff5", + "metadata": {}, + "name": "componentQuery", + "operationKind": "query", + "text": null + } +}; +})(); + +node.hash = "57e4a4c3f66e23aaa4883404c0a73a32"; + +export default node; + + + +Artifact Map: +Project: test_project + Type: Mapping + - Source: ExecutableDefinition: componentQuery + Path: src/__generated__/componentQuery.graphql.js + Persisted ID: a70bad875245c0b80e06c2084c9fbff5 diff --git a/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_created_when_missing.input b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_created_when_missing.input new file mode 100644 index 0000000000000..b0c25eedf31c1 --- /dev/null +++ b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_created_when_missing.input @@ -0,0 +1,33 @@ +//- src/component.js + +graphql`query componentQuery { + me { + name + } +}` + +//- relay.config.json +{ + "sources": { + "src": "test_project" + }, + "projects": { + "test_project": { + "schema": "schema.graphql", + "language": "javascript", + "persist": { + "file": "./persisted_queries.json", + "algorithm": "MD5" + } + } + } +} + +//- schema.graphql +type Query { + me: User +} + +type User { + name: String +} diff --git a/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_existing_entries_preserved.expected b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_existing_entries_preserved.expected new file mode 100644 index 0000000000000..9afdc76fba99d --- /dev/null +++ b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_existing_entries_preserved.expected @@ -0,0 +1,119 @@ +==================================== INPUT ==================================== +//- src/component.js + +graphql`query componentQuery { + me { + name + } +}` + +//- relay.config.json +{ + "sources": { + "src": "test_project" + }, + "projects": { + "test_project": { + "schema": "schema.graphql", + "language": "javascript", + "persist": { + "file": "./persisted_queries.json", + "algorithm": "MD5" + } + } + } +} + +//- persisted_queries.json +{ + "00000000000000000000000000000000": "query previouslyPersistedQuery {\n me {\n name\n }\n}\n" +} + +//- schema.graphql +type Query { + me: User +} + +type User { + name: String +} +==================================== OUTPUT =================================== +//-++ persisted_queries.json +{ + "00000000000000000000000000000000": "query previouslyPersistedQuery {\n me {\n name\n }\n}\n", + "a70bad875245c0b80e06c2084c9fbff5": "query componentQuery {\n me {\n name\n }\n}\n" +} + +//-++ src/__generated__/componentQuery.graphql.js +/** + * SignedSource<<5e6e5f0ef8b7ed3b7ff8eabb168ed5a0>> + * @relayHash a70bad875245c0b80e06c2084c9fbff5 + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +// @relayRequestID a70bad875245c0b80e06c2084c9fbff5 + +var node = (function(){ +var v0 = [ + { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "me", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "componentQuery", + "selections": (v0/*:: as any*/), + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "componentQuery", + "selections": (v0/*:: as any*/) + }, + "params": { + "id": "a70bad875245c0b80e06c2084c9fbff5", + "metadata": {}, + "name": "componentQuery", + "operationKind": "query", + "text": null + } +}; +})(); + +node.hash = "57e4a4c3f66e23aaa4883404c0a73a32"; + +export default node; + + + +Artifact Map: +Project: test_project + Type: Mapping + - Source: ExecutableDefinition: componentQuery + Path: src/__generated__/componentQuery.graphql.js + Persisted ID: a70bad875245c0b80e06c2084c9fbff5 diff --git a/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_existing_entries_preserved.input b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_existing_entries_preserved.input new file mode 100644 index 0000000000000..4c970bb358ba0 --- /dev/null +++ b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_existing_entries_preserved.input @@ -0,0 +1,38 @@ +//- src/component.js + +graphql`query componentQuery { + me { + name + } +}` + +//- relay.config.json +{ + "sources": { + "src": "test_project" + }, + "projects": { + "test_project": { + "schema": "schema.graphql", + "language": "javascript", + "persist": { + "file": "./persisted_queries.json", + "algorithm": "MD5" + } + } + } +} + +//- persisted_queries.json +{ + "00000000000000000000000000000000": "query previouslyPersistedQuery {\n me {\n name\n }\n}\n" +} + +//- schema.graphql +type Query { + me: User +} + +type User { + name: String +} diff --git a/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_missing_parent_directories_created.expected b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_missing_parent_directories_created.expected new file mode 100644 index 0000000000000..e94ef791eb370 --- /dev/null +++ b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_missing_parent_directories_created.expected @@ -0,0 +1,113 @@ +==================================== INPUT ==================================== +//- src/component.js + +graphql`query componentQuery { + me { + name + } +}` + +//- relay.config.json +{ + "sources": { + "src": "test_project" + }, + "projects": { + "test_project": { + "schema": "schema.graphql", + "language": "javascript", + "persist": { + "file": "./persisted/queries.json", + "algorithm": "MD5" + } + } + } +} + +//- schema.graphql +type Query { + me: User +} + +type User { + name: String +} +==================================== OUTPUT =================================== +//-++ persisted/queries.json +{ + "a70bad875245c0b80e06c2084c9fbff5": "query componentQuery {\n me {\n name\n }\n}\n" +} + +//-++ src/__generated__/componentQuery.graphql.js +/** + * SignedSource<<5e6e5f0ef8b7ed3b7ff8eabb168ed5a0>> + * @relayHash a70bad875245c0b80e06c2084c9fbff5 + * @lightSyntaxTransform + */ + +/* eslint-disable */ + +'use strict'; + +// @relayRequestID a70bad875245c0b80e06c2084c9fbff5 + +var node = (function(){ +var v0 = [ + { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "me", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "componentQuery", + "selections": (v0/*:: as any*/), + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "componentQuery", + "selections": (v0/*:: as any*/) + }, + "params": { + "id": "a70bad875245c0b80e06c2084c9fbff5", + "metadata": {}, + "name": "componentQuery", + "operationKind": "query", + "text": null + } +}; +})(); + +node.hash = "57e4a4c3f66e23aaa4883404c0a73a32"; + +export default node; + + + +Artifact Map: +Project: test_project + Type: Mapping + - Source: ExecutableDefinition: componentQuery + Path: src/__generated__/componentQuery.graphql.js + Persisted ID: a70bad875245c0b80e06c2084c9fbff5 diff --git a/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_missing_parent_directories_created.input b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_missing_parent_directories_created.input new file mode 100644 index 0000000000000..788238ab30526 --- /dev/null +++ b/compiler/crates/relay-compiler/tests/relay_compiler_integration/fixtures/persisted_queries_file_missing_parent_directories_created.input @@ -0,0 +1,33 @@ +//- src/component.js + +graphql`query componentQuery { + me { + name + } +}` + +//- relay.config.json +{ + "sources": { + "src": "test_project" + }, + "projects": { + "test_project": { + "schema": "schema.graphql", + "language": "javascript", + "persist": { + "file": "./persisted/queries.json", + "algorithm": "MD5" + } + } + } +} + +//- schema.graphql +type Query { + me: User +} + +type User { + name: String +} diff --git a/compiler/crates/relay-compiler/tests/relay_compiler_integration_test.rs b/compiler/crates/relay-compiler/tests/relay_compiler_integration_test.rs index 81ee00c5dcdf0..0f20ed1ea6c7b 100644 --- a/compiler/crates/relay-compiler/tests/relay_compiler_integration_test.rs +++ b/compiler/crates/relay-compiler/tests/relay_compiler_integration_test.rs @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<4fa0284246e2d17615f4a165bec3297e>> + * @generated SignedSource<> */ mod relay_compiler_integration; @@ -453,6 +453,27 @@ async fn non_relay_file_in_generated_dir_with_custom_output() { test_fixture(transform_fixture, file!(), "non_relay_file_in_generated_dir_with_custom_output.input", "relay_compiler_integration/fixtures/non_relay_file_in_generated_dir_with_custom_output.expected", input, expected).await; } +#[tokio::test] +async fn persisted_queries_file_created_when_missing() { + let input = include_str!("relay_compiler_integration/fixtures/persisted_queries_file_created_when_missing.input"); + let expected = include_str!("relay_compiler_integration/fixtures/persisted_queries_file_created_when_missing.expected"); + test_fixture(transform_fixture, file!(), "persisted_queries_file_created_when_missing.input", "relay_compiler_integration/fixtures/persisted_queries_file_created_when_missing.expected", input, expected).await; +} + +#[tokio::test] +async fn persisted_queries_file_existing_entries_preserved() { + let input = include_str!("relay_compiler_integration/fixtures/persisted_queries_file_existing_entries_preserved.input"); + let expected = include_str!("relay_compiler_integration/fixtures/persisted_queries_file_existing_entries_preserved.expected"); + test_fixture(transform_fixture, file!(), "persisted_queries_file_existing_entries_preserved.input", "relay_compiler_integration/fixtures/persisted_queries_file_existing_entries_preserved.expected", input, expected).await; +} + +#[tokio::test] +async fn persisted_queries_file_missing_parent_directories_created() { + let input = include_str!("relay_compiler_integration/fixtures/persisted_queries_file_missing_parent_directories_created.input"); + let expected = include_str!("relay_compiler_integration/fixtures/persisted_queries_file_missing_parent_directories_created.expected"); + test_fixture(transform_fixture, file!(), "persisted_queries_file_missing_parent_directories_created.input", "relay_compiler_integration/fixtures/persisted_queries_file_missing_parent_directories_created.expected", input, expected).await; +} + #[tokio::test] async fn prefetchable_pagination_rename_fragment_removes_edges() { let input = include_str!("relay_compiler_integration/fixtures/prefetchable_pagination_rename_fragment_removes_edges.input"); diff --git a/compiler/crates/relay-config/src/project_config.rs b/compiler/crates/relay-config/src/project_config.rs index 526eaa4fdd16e..a051e33a3559a 100644 --- a/compiler/crates/relay-config/src/project_config.rs +++ b/compiler/crates/relay-config/src/project_config.rs @@ -124,6 +124,7 @@ pub enum PersistConfig { /// /// When this variant is used, the compiler will attempt to read the local file as a hash map, /// add new queries to the map, and then serialize and write the resulting map to the configured path. + /// The file (and any missing parent directories) is created if it does not already exist. Local(LocalPersistConfig), } @@ -142,16 +143,7 @@ impl<'de> Deserialize<'de> for PersistConfig { match RemotePersistConfig::deserialize(value.clone()) { Ok(remote_config) => Ok(PersistConfig::Remote(remote_config)), Err(remote_error) => match LocalPersistConfig::deserialize(value) { - Ok(local_config) => { - if !local_config.file.exists() { - Err(Error::custom(format!( - "The file `{}` for the local query persisting does not exist. Please, make sure the file path is correct.", - local_config.file.display() - ))) - } else { - Ok(PersistConfig::Local(local_config)) - } - } + Ok(local_config) => Ok(PersistConfig::Local(local_config)), Err(local_error) => { let error_message = format!( r#"Persist configuration cannot be parsed as a remote configuration due to: