docs/plans/5.5-6.0-custom-fields-rename.md
Status: 5.4 serializer bridge ✓; 5.5 alias classes (Spree::CustomField, Spree::CustomFieldDefinition) ✓; 6.0 model/table rename pending
Target: 5.4 (Store API serializer bridge ✓) + 5.5 (Admin CRUD API + SDK + Spree::CustomField constant alias ✓) + 6.0 (model/table rename + visibility simplification + STI rename)
Depends on: None — 5.4/5.5 changes are additive. 6.0 rename is part of the model rename wave.
Author: Damian + Claude
Last updated: 2026-05-20
Rename the "Metafields" system to "Custom Fields" and simplify the visibility model. The term "metafields" (borrowed from Shopify) creates confusion with the separate "metadata" system. "Custom Fields" is self-explanatory, merchant-friendly, and industry-standard (used by Vendure, commercetools, Salesforce, HubSpot, Jira, Linear).
In 6.0, simplify the three-way display_on (both/front_end/back_end) to a single boolean storefront_visible on definitions. This makes the two-system design razor-sharp:
The rename ships in three phases:
Spree::CustomField / Spree::CustomFieldDefinition constant aliases so controllers, serializers, and 5.5+ extensions reference the model by its 6.0-bound name. Plus model-level alias_attributes for the API field names (custom_field_definition_id, field_type, label, storefront_visible). No schema changes; no deprecation warnings yet.alias_attribute shims.metadata JSON column is the developer escape hatch. Never exposed in Store API. Not going away. Not being consolidated into custom fields.public_metadata is deprecated — never was exposed in Store API, served same purpose as private_metadata. Single metadata accessor going forward. Deprecation warning when writing to public_metadata in 5.4, column dropped in 6.0.display_on (both/front_end/back_end) → storefront_visible (boolean, default: true). The front_end-only option was already excluded from MetafieldDefinition::DISPLAY and never made sense. This is a schema change and ships with the 6.0 model rename wave.display_on column stays until 6.0.Spree::CustomField/Spree::CustomFieldDefinition as constant aliases for the existing models. This lets controllers, serializers, and extensions reference the 6.0-bound name today. No schema changes, no deprecation warnings — the rename is a constant-level shim until 6.0 ships the actual class/table rename.metadata.| Platform | Unstructured (developer) | Structured (merchant/admin) | Naming clarity |
|---|---|---|---|
| Shopify | Metafields (no definition) | Metafields (with MetafieldDefinition) | Confusing — same name for both |
| Saleor | metadata / privateMetadata | Attributes (via ProductType) | Clear, but "Attributes" is ambiguous |
| Vendure | None built-in | Custom Fields (code config) | Clear |
| commercetools | Custom Objects (JSON) | Custom Fields (via Type + FieldDefinition) | Clear |
| Medusa v2 | metadata JSON property | Custom Modules (code-based) | Clear for metadata, heavy for structured |
| Spree (current) | metadata JSON column | Metafields (via MetafieldDefinition) | Confusing — "meta" prefix collision |
| Spree (proposed) | metadata JSON column | Custom Fields (via CustomFieldDefinition) | Clear |
| Platform | Visibility control | Private developer store |
|---|---|---|
| Shopify | access: { admin, storefront } per MetafieldDefinition | Metafields without definitions |
| Saleor | visibleInStorefront: boolean per Attribute | metadata / privateMetadata |
| Vendure | public: boolean per Custom Field (default: true) | No built-in unstructured store |
| commercetools | No visibility toggle on Custom Fields | Custom Objects (separate system) |
| Spree (current) | display_on: both/front_end/back_end | metadata JSON column |
| Spree (6.0) | storefront_visible: boolean (default: true) | metadata JSON column |
Our proposed storefront_visible boolean matches Vendure and Saleor exactly. The three-way display_on was overengineered — front_end-only (visible to customers but not admins) was already excluded and makes no sense.
Current display_on options on MetafieldDefinition:
both — Store API + Admin API (the common case)back_end — Admin API only (overlaps with metadata's purpose)front_end — already excluded from MetafieldDefinition::DISPLAY, never usedProblems:
back_end-only metafields overlap with metadata's purpose — merchants creating a "back_end" metafield are essentially using typed metadatapublic_metafields / private_metafields associations on models add complexityReplace display_on (string) with storefront_visible (boolean, default: true):
storefront_visible | Store API | Admin API | Use case |
|---|---|---|---|
true (default) | Yes | Yes | Product specs, customer-facing attributes |
false | No | Yes | Internal notes, compliance classification, supplier data |
The valid use case for admin-only structured fields (internal margin tier, supplier cost notes, compliance tags) is preserved via storefront_visible: false. These need schema enforcement and admin UI forms — metadata is wrong for them because it's schemaless and has no form inputs.
On Spree::CustomFieldDefinition (renamed from MetafieldDefinition):
storefront_visible boolean column (default: true, null: false)display_on IN ('both', 'front_end') → storefront_visible: true, display_on = 'back_end' → storefront_visible: falsedisplay_on columninclude Spree::DisplayOnOn Spree::HasCustomFields concern (renamed from Metafields) — simplify associations:
# Before
has_many :metafields, ...
has_many :public_metafields, -> { available_on_front_end }, ...
has_many :private_metafields, -> { available_on_back_end }, ...
# After
has_many :custom_fields, ...
has_many :storefront_custom_fields, -> { storefront_visible }, ...
# private_metafields removed entirely
Scopes on Spree::CustomField:
# Before
scope :available_on_front_end, -> { joins(:metafield_definition).merge(MetafieldDefinition.available_on_front_end) }
scope :available_on_back_end, -> { joins(:metafield_definition).merge(MetafieldDefinition.available_on_back_end) }
# After
scope :storefront_visible, -> { joins(:custom_field_definition).where(spree_custom_field_definitions: { storefront_visible: true }) }
The Store API surface is very small — metafields have no dedicated endpoints, only expand-based inclusion on 3 serializers:
| Layer | Files to change | Nature of change | Status |
|---|---|---|---|
| Store API serializers | 4 | Rename expand param + response key + serializer class | ✅ shipped |
| Admin API serializers | 4 | Same, keep display_on as-is | ✅ shipped |
| Admin API controllers | 1 | Rename endpoint path | ⏳ pending — see CRUD section |
| Dependencies registry | 1 | Rename keys | ✅ shipped |
| Prefix IDs (models) | 2 | Change prefix strings | ✅ shipped |
| Integration specs | 1 | Update expand param | ✅ shipped |
| Serializer unit tests | 2 | Update assertions | ✅ shipped |
| SDK types (auto-generated) | 4 | Regenerated | ✅ shipped |
| SDK Zod schemas (auto-generated) | 3 | Regenerated | ✅ shipped |
| OpenAPI spec (auto-generated) | 1 | Regenerated | ✅ shipped |
| Total | ~23 |
No migrations. No Store API controllers touch metafields — all access is via ?expand= on products, variants, and categories. Zero client SDK methods to rename (no CRUD endpoints for metafields in Store API).
CRUD API for the Admin ships separately as part of 5.4 — see Custom Fields CRUD API + SDK below.
| Model | Old prefix | New prefix |
|---|---|---|
Spree::Metafield | mf_ | cf_ |
Spree::MetafieldDefinition | mfdef_ | cfdef_ |
find_by_prefix_id! ignores the prefix during decoding (splits on _ and decodes Sqids portion), so old IDs with old prefixes still resolve correctly.
Rename expand parameter and response key from metafields to custom_fields:
# ProductSerializer — before
many :public_metafields,
key: :metafields,
resource: Spree.api.metafield_serializer,
if: proc { expand?('metafields') }
# ProductSerializer — after
many :public_metafields,
key: :custom_fields,
resource: Spree.api.custom_field_serializer,
if: proc { expand?('custom_fields') }
Same change on: VariantSerializer, CategorySerializer.
The underlying public_metafields association stays unchanged — it still filters by display_on internally. Only the API surface (expand param, response key, serializer class) changes.
New file api/app/serializers/spree/api/v3/custom_field_serializer.rb:
module Spree
module Api
module V3
class CustomFieldSerializer < BaseSerializer
typelize key: :string, label: :string, type: :string, value: :any
attributes :label, :type
attribute :key do |metafield|
metafield.full_key
end
attribute :value do |metafield|
metafield.serialize_value
end
end
end
end
end
Serializes Spree::Metafield model — same data, new file name and class name. Old MetafieldSerializer file is deleted.
label not name — Aligns with OptionType/OptionValue which also expose the human-readable display string as label. The programmatic identifier is key. MetafieldDefinition has a label alias on name (same delegating-method pattern as OptionType's label → presentation).
Rename expand parameter and response key. Keep display_on until 6.0:
# Admin::ProductSerializer — before
many :metafields,
resource: Spree.api.admin_metafield_serializer,
if: proc { expand?('metafields') }
# Admin::ProductSerializer — after
many :metafields,
key: :custom_fields,
resource: Spree.api.admin_custom_field_serializer,
if: proc { expand?('custom_fields') }
Same change on: Admin::VariantSerializer, Admin::CategorySerializer.
New file api/app/serializers/spree/api/v3/admin/custom_field_serializer.rb:
module Spree
module Api
module V3
module Admin
class CustomFieldSerializer < V3::CustomFieldSerializer
typelize display_on: :string
attributes :display_on
end
end
end
end
end
In 6.0, display_on becomes storefront_visible: boolean.
Rename metafield_type → field_type in serializer output. Keep display_on until 6.0:
# Before
attributes :display_on, :metafield_type
# After
attributes :display_on
attribute :field_type do |definition|
definition.metafield_type
end
# Before
GET/POST /api/v3/admin/metafield_definitions
GET/PATCH/DELETE /api/v3/admin/metafield_definitions/:id
# After
GET/POST /api/v3/admin/custom_field_definitions
GET/PATCH/DELETE /api/v3/admin/custom_field_definitions/:id
Controller file rename: admin/metafield_definitions_controller.rb → admin/custom_field_definitions_controller.rb. Internally still uses Spree::MetafieldDefinition model.
# Before
metafield_serializer: 'Spree::Api::V3::MetafieldSerializer',
admin_metafield_serializer: 'Spree::Api::V3::Admin::MetafieldSerializer',
# After — add new keys, remove old
custom_field_serializer: 'Spree::Api::V3::CustomFieldSerializer',
admin_custom_field_serializer: 'Spree::Api::V3::Admin::CustomFieldSerializer',
custom_field_definition_serializer: 'Spree::Api::V3::Admin::CustomFieldDefinitionSerializer',
// Before
interface StoreMetafield { id: string; name: string; type: string; key: string; value: any }
client.products.list({ expand: ['metafields'] })
product.metafields
// After
interface StoreCustomField { id: string; label: string; type: string; key: string; value: any }
client.products.list({ expand: ['custom_fields'] })
product.custom_fields
Generated types rename: StoreMetafield → StoreCustomField, AdminMetafield → AdminCustomField, AdminMetafieldDefinition → AdminCustomFieldDefinition. The name field is renamed to label to align with OptionType/OptionValue naming.
Add deprecation warning in the Spree::Metadata concern when public_metadata= is called:
def public_metadata=(value)
Spree::Deprecation.warn(
"public_metadata is deprecated. Use metadata instead. " \
"For customer-visible structured data, use metafields with display_on: 'both'."
)
super
end
Update docs to advise using only the metadata accessor.
Spree::Metafield, Spree::MetafieldDefinition)spree_metafields, spree_metafield_definitions)display_on column stays)Spree::Metafields::ShortText, etc.)Spree::Metafields, Spree::Metadata)has_many :metafields, has_many :public_metafields)Spree.metafields configurationalias_attributesTwo thin shims ship in 5.5 so that controllers, serializers, and any 5.5+ extension code can use the new names today without waiting for the 6.0 model/table rename:
# spree/core/app/models/spree/custom_field.rb
module Spree
CustomField = Metafield
end
# spree/core/app/models/spree/custom_field_definition.rb
module Spree
CustomFieldDefinition = MetafieldDefinition
end
A constant alias means Spree::CustomField.equal?(Spree::Metafield) is true — same class, two names. STI works (the parent's type column still resolves to Spree::Metafields::ShortText etc.), find_by_prefix_id! works, all associations work. This explicitly is not a subclass — earlier exploration confirmed that subclassing fights STI (Spree::Metafields::ShortText would not be < Spree::CustomField).
What you get:
Spree::CustomField / Spree::CustomFieldDefinition directlySpree::Metafield in their own codeMetafield with CustomField (mechanical), do the table/class rename in one waveWhat you don't get (intentional):
model_name.name still says 'Spree::Metafield' — URL helpers, error messages, I18n keys all use the legacy name. That's a 6.0 concern.Spree::Metafields::ShortText STI subclasses keep their namespace until 6.0.Spree::Metafield references — 5.5 is a soft introduction, not a deprecation.alias_attribute for API field namesThe model exposes the API-facing field names as alias_attributes pointing at the existing columns:
class Spree::Metafield
alias_attribute :custom_field_definition_id, :metafield_definition_id
end
class Spree::MetafieldDefinition
alias_attribute :label, :name
alias_attribute :field_type, :metafield_type
# `display_on` is an enum string ('both'/'front_end'/'back_end'); expose
# `storefront_visible` as a boolean accessor.
def storefront_visible; available_on_front_end?; end
def storefront_visible=(value)
self.display_on = ActiveModel::Type::Boolean.new.cast(value) ? 'both' : 'back_end'
end
end
The Spree::PrefixedId concern automatically resolves prefixed-ID values through alias_attribute (it walks attribute_aliases when assigning), so a controller can do params.permit(:custom_field_definition_id, :value) and have AR's assign_attributes write to the correct column with the prefixed ID decoded.
This is the entire alias surface. Keep controllers thin permit-and-pass; the API ↔ DB rename lives in the model.
field_type tokens (5.5)Both 5.4 serializers shipped Spree::Metafields::ShortText-style class strings via the AR type column and the metafield_type column. That leaks Ruby internals across both Store and Admin APIs and breaks every SDK consumer in 6.0 when the STI namespace renames to Spree::CustomFields::*. Every major competitor (Shopify, Saleor, Vendure, commercetools) exposes abstract tokens, not class names — Shopify migrated explicitly in 2022.
5.5 introduces a token vocabulary for custom-field types:
| API token | Internal STI class |
|---|---|
short_text | Spree::Metafields::ShortText |
long_text | Spree::Metafields::LongText |
rich_text | Spree::Metafields::RichText |
number | Spree::Metafields::Number |
boolean | Spree::Metafields::Boolean |
json | Spree::Metafields::Json |
Implementation:
class Spree::Metafield
TYPE_TOKENS = { 'short_text' => 'Spree::Metafields::ShortText', ... }.freeze
TYPE_CLASS_TO_TOKEN = TYPE_TOKENS.invert.freeze
FIELD_TYPE_TS_UNION = TYPE_TOKENS.keys.map { |t| "'#{t}'" }.join(' | ').freeze
def field_type
TYPE_CLASS_TO_TOKEN[self[:type]] || self[:type]
end
end
class Spree::MetafieldDefinition
def field_type
Spree::Metafield::TYPE_CLASS_TO_TOKEN[metafield_type] || metafield_type
end
def field_type=(value)
self.metafield_type = Spree::Metafield::TYPE_TOKENS[value.to_s] || value
end
end
Reads always emit the token. Writes accept either the token (short_text) or the legacy class string (Spree::Metafields::ShortText) for back-compat — external integrations that wrote the old form keep working. Fall-through preserves plugin-defined types (e.g. MyExt::Metafields::Color) until a registration API lands in 6.0.
Wire-format changes in 5.5 (all additive — no breaking changes):
Spree::Api::V3::CustomFieldSerializer — adds field_type (token) alongside the legacy type (class string). Both ship.Admin::CustomFieldSerializer — same: adds field_type, keeps type.Admin::CustomFieldDefinitionSerializer — field_type value changed from class string → token (field name was already field_type). This is the one shape change consumers must adapt to.Removal of the legacy type field on CustomFieldSerializer ships in a follow-up minor (deprecation removed once consumers have migrated to field_type).
The SDK type field_type becomes a discriminated union ('short_text' | 'long_text' | 'rich_text' | 'number' | 'boolean' | 'json') so storefront and admin consumers get type-narrowable values.
Full internal rename. Part of the 6.0 model rename wave alongside Shipment→Fulfillment, ShippingMethod→DeliveryMethod, etc.
| Current | 6.0 | Notes |
|---|---|---|
Spree::Metafield | Spree::CustomField | |
Spree::MetafieldDefinition | Spree::CustomFieldDefinition | |
Spree::Metafields::ShortText | Spree::CustomFields::ShortText | STI subclass |
Spree::Metafields::LongText | Spree::CustomFields::LongText | STI subclass |
Spree::Metafields::RichText | Spree::CustomFields::RichText | STI subclass |
Spree::Metafields::Number | Spree::CustomFields::Number | STI subclass |
Spree::Metafields::Boolean | Spree::CustomFields::Boolean | STI subclass |
Spree::Metafields::Json | Spree::CustomFields::Json | STI subclass |
Spree::Metafields concern | Spree::HasCustomFields | |
spree_metafields table | spree_custom_fields | |
spree_metafield_definitions table | spree_custom_field_definitions | |
metafield_definition_id FK | custom_field_definition_id FK | |
metafield_type column | field_type column | On definitions table |
display_on column (string) | storefront_visible column (boolean) | On definitions table |
Spree.metafields config | Spree.custom_fields config | |
set_metafield / get_metafield | set_custom_field / get_custom_field | Instance methods |
has_metafield? | has_custom_field? | Instance method |
with_metafield_key scope | with_custom_field_key scope | |
with_metafield_key_value scope | with_custom_field_key_value scope | |
public_metafields assoc | storefront_custom_fields assoc | |
private_metafields assoc | removed | |
Admin serializer display_on: string | storefront_visible: boolean | |
Definition name column | label column | 5.4 adds alias, 6.0 renames column |
public_metadata column from all tablesprivate_metadata → metadata in the databaseSpree::Metadata concern to single metadata column with no alias indirectionspree_metafields → spree_custom_fields, spree_metafield_definitions → spree_custom_field_definitionsmetafield_definition_id → custom_field_definition_id, metafield_type → field_typedisplay_on with storefront_visible:
add_column :spree_custom_field_definitions, :storefront_visible, :boolean, default: true, null: false
# Data migration: back_end → false, both/front_end → true
remove_column :spree_custom_field_definitions, :display_on
type column values: Spree::Metafields::ShortText → Spree::CustomFields::ShortText, etc.public_metadata, rename private_metadata → metadata on all tablesmetafield.custom.id → custom_field.custom.id
The 5.4 serializer bridge exposes custom fields as a read-only ?expand=custom_fields payload, but no CRUD endpoints exist. The admin SPA's <CustomFieldsDrawer> (per 6.0-admin-spa.md Tier 2) is gated on these endpoints. This section scopes them.
29 models include Spree::Metafields today (Variant, Order, Product, Customer, Category, OptionType, Taxon, Shipment, Payment, Refund, GiftCard, StoreCredit, Address, LineItem, Store, Promotion, ShippingMethod, TaxRate, StockItem, StockTransfer, CustomerReturn, CreditCard, PaymentMethod, PaymentSource, NewsletterSubscriber, PaymentSession, PaymentSetupSession, Asset, Taxonomy). Every one of those resources benefits from in-admin CRUD. Building the API once + SDK once means every detail page in the SPA can opt into custom fields with a single <PageHeader> prop.
Two routing surfaces because the data is two-tiered:
material: "wool" value). They mount under each parent that includes Spree::Metafields.Spree::Product shares the same definitions). They live at a flat endpoint, filtered by ?resource_type=.# spree/api/config/routes.rb (admin namespace)
concern :custom_fieldable do
resources :custom_fields, controller: 'custom_fields'
end
namespace :admin do
resources :custom_field_definitions # flat — definitions are per resource type
resources :products, concerns: :custom_fieldable do; ...; end
resources :variants, concerns: :custom_fieldable, only: %i[index show]
resources :orders, concerns: :custom_fieldable do; ...; end
resources :customers, concerns: :custom_fieldable do; ...; end
resources :categories, concerns: :custom_fieldable, only: %i[index show]
resources :option_types, concerns: :custom_fieldable
# …extend to remaining `Spree::Metafields`-bearing parents incrementally
end
URLs:
# Per-instance custom field values (nested)
GET /api/v3/admin/products/prod_xxx/custom_fields
POST /api/v3/admin/products/prod_xxx/custom_fields
PATCH /api/v3/admin/products/prod_xxx/custom_fields/cf_xxx
DELETE /api/v3/admin/products/prod_xxx/custom_fields/cf_xxx
# Per-type custom field definitions (flat, filter by resource_type)
GET /api/v3/admin/custom_field_definitions?resource_type=Spree::Product
POST /api/v3/admin/custom_field_definitions
PATCH /api/v3/admin/custom_field_definitions/cfdef_xxx
DELETE /api/v3/admin/custom_field_definitions/cfdef_xxx
Why the concern over polymorphic /custom_fields?owner_type=...&owner_id=...: consistent with the rest of the Admin API (orders/items, customers/addresses, products/media all use nested resources). Keeps URLs human-readable. Plugin parents apply the concern in their own routes file — zero core changes when a plugin adds a new metafield-bearing model.
One Admin::CustomFieldsController and one Admin::CustomFieldDefinitionsController, both inheriting from the existing Spree::Api::V3::Admin::ResourceController. They infer the parent class from whichever :<parent>_id param is present in the route, then scope all reads/writes through the parent's metafields association (or metafield_definitions for definitions). Authorization uses CanCanCan against the parent — authorize! :update, parent for create/update/delete, authorize! :show, parent for index/show.
module Spree::Api::V3::Admin
class CustomFieldsController < ResourceController
protected
def model_class
Spree::Metafield
end
def serializer_class
Spree.api.admin_custom_field_serializer
end
def scope
parent.metafields
end
# Inferred from the first `:<parent>_id` param present.
def parent
@parent ||= load_parent_from_route
end
# Permits only the API-facing fields; `key` and `type` are derived from
# the linked CustomFieldDefinition via the model's before_validation
# callback. Permitting `:type` would let API clients pick STI subclasses
# directly — unsafe.
#
# `custom_field_definition_id` is an `alias_attribute` on `Spree::Metafield`
# for the internal `metafield_definition_id` column. AR's `assign_attributes`
# also resolves the prefixed-ID through the alias automatically.
def permitted_params
params.permit(:custom_field_definition_id, :value)
end
end
end
Naming aliases live in the models, not in controllers. The controller stays a thin permit-and-pass layer; the API ↔ DB column rename is encoded once on Spree::Metafield and Spree::MetafieldDefinition:
class Spree::Metafield
alias_attribute :custom_field_definition_id, :metafield_definition_id
end
class Spree::MetafieldDefinition
alias_attribute :label, :name
alias_attribute :field_type, :metafield_type
# `display_on` is an enum string ('both'/'front_end'/'back_end'); expose a
# boolean as the API surface.
def storefront_visible
available_on_front_end?
end
def storefront_visible=(value)
self.display_on = ActiveModel::Type::Boolean.new.cast(value) ? 'both' : 'back_end'
end
end
Drops cleanly in 6.0 when the columns rename: delete the alias_attribute lines, replace display_on/storefront_visible with the boolean column.
load_parent_from_route walks a small map of param_name → model_class, picks the first one present, calls find_by_prefix_id!. Same pattern any future polymorphic admin controller will use.
custom_field_definition_id is required — Spree::Metafield validates metafield_definition presence. There is no free-form mode (clients can't supply key + type without a definition).type is auto-set from the linked definition's metafield_type in a before_validation callback. Clients never supply it.(metafield_definition_id, resource_type, resource_id) is unique — DB-enforced. Updating an existing custom field's value is the upsert path; there is no "patch the definition link" operation (delete and re-create instead).value is validated by the type's STI subclass (ShortText, Number, etc.).No new validation logic at the controller layer — the model and the database carry it.
Hand-write SDK methods on the six most-used parents in the SPA. Plugins extend the SDK via the same TS pattern — they import createCustomFieldsApi and attach it to their own resource client. Everything else uses a generic.
First-class parents (6):
adminClient.products.customFields // .list, .get, .create, .update, .delete
adminClient.variants.customFields // (when variants get a top-level admin route)
adminClient.orders.customFields
adminClient.customers.customFields
adminClient.categories.customFields
adminClient.optionTypes.customFields
Same pattern for definitions:
adminClient.products.customFieldDefinitions
adminClient.orders.customFieldDefinitions
…
Generic escape hatch:
adminClient.customFields(ownerType: 'Spree::Product', ownerId: 'prod_xxx').list()
Internally, the generic is a tiny URL builder that maps ownerType → admin URL segment (Spree::Product → products). Plugins can also call this directly without registering a first-class binding. Used sparingly — most SPA pages use the first-class methods because they read better.
Why six and not all 29: the SPA's detail-page surface is products, orders, customers (live today) plus variants, categories, option_types (Phase 4 of 6.0-admin-spa.md). The remaining 23 are configuration-screen owners (StockItem, ShippingMethod, etc.) — when they need custom-field UI, the generic works fine, and we can promote them to first-class when the second consumer asks.
SDK file structure:
packages/admin-sdk/src/
├── resources/
│ ├── custom-fields.ts # createCustomFieldsApi(client, parentPath)
│ ├── custom-field-definitions.ts # createCustomFieldDefinitionsApi(...)
│ ├── products.ts # adds .customFields = createCustomFieldsApi(client, `/products/${id}`)
│ ├── orders.ts
│ └── …
├── admin-client.ts # exposes generic .customFields(ownerType, ownerId)
Each first-class binding is one line in its parent's resource file: customFields: createCustomFieldsApi(client, ...). Adding a seventh first-class parent later is a one-line change.
Definitions are managed in a separate UI surface (Settings → Custom Field Definitions, similar to the Rails admin's existing metafield_definitions page). The CRUD endpoints above support it; the SPA page is Phase 4 of 6.0-admin-spa.md. The drawer reads definitions to type-aware-render the editor but doesn't manage them.
<CustomFieldsDrawer> consumes the six bindings, gated by <PageHeader jsonPreview>-style propcustomFieldDefinitions endpoint for type-aware editorexpand: ['metafields'] → expand: ['custom_fields'] in all API callsStoreMetafield → StoreCustomFieldmf_ → cf_, mfdef_ → cfdef_ (lookup still works with old prefixes)metafields → custom_fieldsSpree::Metafield → Spree::CustomFieldinclude Spree::Metafields → include Spree::HasCustomFieldsset_metafield → set_custom_field, etc.Spree.metafields → Spree.custom_fields configuration referencespublic_metadata / private_metadata with single metadatadisplay_on with storefront_visible booleancustom_fields naming after the 5.4 bridge ships.custom_fields / custom_field_definitions naming after the 5.4 bridge ships.custom_fields after this ships.metafield naming until 6.0.public_metadata — use metadata accessor instead. public_metadata will emit deprecation warnings in 5.4.5.4-store-api-bridges.md — same bridge pattern (API naming changed in 5.4, models renamed in 6.0)5.4-6.0-product-media-system.md — images→media bridge6.0-product-types.md — ProductType gains has_many :custom_field_definitions (terminology updates needed)5.4-search-provider.md — CustomFieldDefinition.filterable for faceting (terminology updates needed)decisions.md — "Consolidate metadata" entry confirms two-system design