docs/plans/6.0-store-scoped-custom-field-definitions.md
filter_keyStatus: Draft
Target: Spree 6.0
Depends on: 5.4-6.0-custom-fields-rename.md, 5.6-6.0-single-store-promotions-payment-methods.md
Author: Damian Legawiec
Last updated: 2026-07-27
Spree::MetafieldDefinition (public name: Spree::CustomFieldDefinition) is
currently global — it has no store_id, and nothing scopes it by store.
Every store in an installation shares one custom-field schema, and the Admin API
lets any store's admin read and edit every other store's definitions.
Two related changes are deferred here from the 5.6 custom-field search/sort/filter work (that PR shipped as a patch release and these are schema changes):
store_id on definitions — make custom-field schema store-owned, matching
the single-owner direction already taken for Product, Promotion, and
PaymentMethod.filter_key — the cf_<namespace>_<key> identifier used to sort
and filter product listings is currently computed in Ruby on every call. A
column lets it be uniquely indexed and looked up directly.Together they make uniqueness (store_id, resource_type, filter_key), enforced
by both a model validation and a DB unique index.
filter_key is the name, not search_key — the value drives sorting and
filtering, not just text search. full_key was already taken: it returns the
dotted custom.material form used by CSV headers (metafield.custom.material)
and the Store API's key field, and must not be repurposed.cf_<namespace>_<key>. Not cf_<namespace.length>_... (the
original length-delimited form, dropped as unreadable) and not the definition's
prefixed ID (opaque, and not portable across environments).key is already unique per
(resource_type, namespace), but both segments normalize to underscored slugs,
so ("a_b", "c") and ("a", "b_c") both flatten to cf_a_b_c — one of them
would be unaddressable. Until the column exists this is a CONCAT-based
validation (MetafieldDefinition::SearchCapabilities#filter_key_must_be_unique);
after, it becomes a plain uniqueness validation plus a unique index.has_many on Store, not a for_store scope — the
association is the scoping mechanism (store.metafield_definitions).add_column :spree_metafield_definitions, :filter_key, :string
add_reference :spree_metafield_definitions, :store, index: true # nullable at 5.x, null: false at 6.0
add_index :spree_metafield_definitions, [:store_id, :resource_type, :filter_key],
unique: true, name: 'index_metafield_definitions_on_store_and_filter_key'
Spree::Store gains has_many :metafield_definitions, dependent: :destroy_async
plus a custom_field_definitions alias.Spree::MetafieldDefinition gains belongs_to :store, a
before_validation :set_filter_key callback (so the value follows a renamed
namespace/key and back-fills on next write), and store-scoped uniqueness on
both key and filter_key.set_default_store
falls back to Spree::Current.store || Spree::Store.default.All of these currently fetch definitions with for_resource_type and no store
filter. The CSV pairs must stay in lockstep or header and value columns
misalign:
| Location | Note |
|---|---|
Spree::Export#metafields_headers | header row |
Spree::Product#to_csv | value row — must match the header scope |
Spree::Order#to_csv | value row — takes a store arg that is currently ignored (_store) |
Spree::Import#metafield_definitions_for_model | |
Spree::Product.search scope | |
Spree::SearchProvider::MetafieldSchema#product_definitions | needs a store constructor arg |
Spree::Metafields.ensure_metafield_definition_exists! | auto-creates — needs a store |
Spree::Metafields#resolve_metafield_definition_id_from_string | auto-creates via the "namespace.key" form |
Api::V3::Admin::CustomFieldDefinitionsController#scope | currently unscoped — cross-store read/write |
A rake task registered in the upgrade manifest, run post-db:migrate:
store_id = Spree::Store.default.id to every definition missing one.filter_key on rows still missing it, via valid? (which fires the
callback) then save!(validate: false) so unrelated legacy data doesn't block
the column backfill.has_many/belongs_to,
ship the backfill task in the upgrade manifest, switch every call site above
to the association. NULL store_id rows keep working until backfilled.null: false on store_id and filter_key, drop
the CONCAT validation in favour of the plain uniqueness validation, and drop
any global-row fallbacks.Until this lands:
filter_key is a computed method, not a column — don't add a Ransack
predicate, an order(:filter_key), or a where(filter_key: …) against it.
Filter it in Ruby or match on namespace/key.filter_key_must_be_unique, a CONCAT-based
validation. It's not backed by a DB index, so it can't hold under concurrent
creates. Don't rely on it as a hard guarantee.for_store scope to MetafieldDefinition — the has_many on
Store is the intended mechanism.spree_multi_store want to keep sharing definitions across stores, the
way it does for promotions and payment methods? If so the sharing bridge
belongs in that extension, not core.Spree::Metafield rows point at a definition, so a definition's store is
implied by its resource. Worth validating that a metafield's resource and its
definition agree on the store, or is that over-constraining?docs/plans/5.4-6.0-custom-fields-rename.md — the Metafield → CustomField renamedocs/plans/5.6-6.0-single-store-promotions-payment-methods.md — the same
single-owner migration for Promotion and PaymentMethoddocs/developer/core-concepts/metafields.mdx — search/sort/filter docs