Metafield API Functions
Metafields attach merchant-defined structured data to core entities. A definition describes one field (its owner type, namespace, key, data type, validations); the values live in the owning table's meta_data JSONB column.
Two layers make up the public surface:
@evershop/evershop/lib/metafield— owner-agnostic: definition CRUD, validation, output shaping, theme provisioning.- The per-module service barrels — owner-scoped convenience wrappers with the owner baked in (
addProductMetafieldDefinition,setOrderMetafield, …).
Import
import {
createMetafieldDefinition,
updateMetafieldDefinition,
deleteMetafieldDefinition,
listMetafieldDefinitions,
getMetafieldDefinition,
validateMetafields,
validateMetafield,
shapeMetafields,
buildProjection,
provisionThemeMetafields,
createDefinitionCache,
listDefinitionsCached,
compileField,
WIRED_METAFIELD_OWNERS
} from '@evershop/evershop/lib/metafield';
import type {
MetafieldDefinition,
FieldDescriptor,
MetafieldType,
Validation,
MetaData,
ShapedMetafield,
CreateDefinitionInput,
UpdateDefinitionInput
} from '@evershop/evershop/lib/metafield';
Unlike widgets, carriers or payment methods, metafield definitions are ordinary database rows. They can be created, updated and deleted at runtime — from the admin UI, from a REST call, from a migration, from a cron job. There is no registry and no lock. The only thing that runs at boot is theme provisioning.
Types
type MetafieldType =
| 'short_text' | 'long_text' | 'rich_text'
| 'integer' | 'number' | 'boolean'
| 'date' | 'color' | 'url' | 'group';
interface FieldDescriptor {
key: string;
name: string;
description?: string;
type: MetafieldType;
isList?: boolean;
required?: boolean;
translatable?: boolean;
validations?: Validation[];
appearance?: Record<string, unknown>;
subFields?: FieldDescriptor[];
}
interface MetafieldDefinition extends FieldDescriptor {
uuid: string;
ownerType: string;
namespace: string;
visibleToCustomer: boolean;
position: number;
provisionedByTheme?: string;
}
/** Values, keyed namespace -> field key -> value. */
type MetaData = Record<string, Record<string, unknown>>;
group fields nest via subFields; the root group is level 1 and the maximum depth is 3 (MAX_DEPTH).
Owner types
WIRED_METAFIELD_OWNERS lists the owners with complete wiring — a meta_data column, a write path, a prune subscriber, storefront GraphQL and an admin card:
['product', 'category', 'collection', 'customer', 'order', 'shop', 'blog_post', 'blog_category']
owner_type is an open varchar, so other strings are accepted — but a definition for an unwired owner is silently inert. The theme-manifest validator warns about them.
Definition CRUD
createMetafieldDefinition
createMetafieldDefinition(
input: CreateDefinitionInput
): Promise<MetafieldDefinition>
| Field | Type | Required | Description |
|---|---|---|---|
ownerType | string | Yes | One of the wired owners (or a custom string). |
key | string | Yes | Field key, unique per (ownerType, namespace). |
name | string | Yes | Admin-facing label. |
type | MetafieldType | Yes | Data type. |
namespace | string | No | Defaults to 'custom'. |
description | string | No | Help text. |
isList | boolean | No | Repeatable field. Defaults to false. |
required | boolean | No | Defaults to false. |
translatable | boolean | No | Defaults to false. |
visibleToCustomer | boolean | No | Defaults to true. false hides the field from the storefront audience. |
validations | Validation[] | No | size / range / regexp / choices. |
appearance | Record<string, unknown> | No | UI hints. appearance.placeholder is the single source of a storefront default. |
subFields | FieldDescriptor[] | No | Only for type: 'group'. |
position | number | No | Sort order. Defaults to 0. |
provisionedByTheme | string | No | Theme attribution. Setting it opts the row into the deletion guard below. |
Returns the created MetafieldDefinition and emits metafield_definition_created.
Throws (errors carry a status property):
| Status | Condition |
|---|---|
400 | ownerType, key, name or type missing. |
400 | The descriptor does not compile — nesting deeper than 3 levels, a malformed group, an unusable validation. |
409 | A metafield definition "<namespace>.<key>" already exists for "<ownerType>". |
import { createMetafieldDefinition } from '@evershop/evershop/lib/metafield';
const def = await createMetafieldDefinition({
ownerType: 'product',
namespace: 'spec',
key: 'care_instructions',
name: 'Care instructions',
type: 'long_text',
visibleToCustomer: true,
validations: [{ type: 'size', max: 500 }]
});
updateMetafieldDefinition
updateMetafieldDefinition(
uuid: string,
patch: UpdateDefinitionInput
): Promise<MetafieldDefinition>
Patches the mutable fields: name, description, required, translatable, visibleToCustomer, validations, appearance, subFields, position. Emits metafield_definition_updated.
Throws:
| Status | Condition |
|---|---|
404 | Metafield definition "<uuid>" not found. |
400 | "<field>" cannot be changed after creation — the immutable set is ownerType, namespace, key, type, isList. Passing the same value is a no-op; passing a different one throws. |
400 | The patched descriptor no longer compiles. |
deleteMetafieldDefinition
deleteMetafieldDefinition(
uuid: string,
opts?: { force?: boolean }
): Promise<void>
Deletes the definition and, in the same transaction, emits metafield_definition_deleted with { ownerType, namespace, fieldKey }. Each owning module's prune subscriber strips that key from every row of its table's meta_data — so deleting a definition drops the stored values store-wide.
Throws:
| Status | Condition |
|---|---|
404 | Metafield definition "<uuid>" not found. |
409 | Theme-provenance guard — see below. |
A definition carrying provisionedByTheme is protected when that theme is the active theme, or when it appears in theme_install_state. Deleting it would drop values store-wide and the theme would re-seed the field empty at the next boot. The error reads:
"<namespace>.<key>" is provisioned by theme "<theme>" — deleting it would drop
stored values store-wide and the theme will re-seed it. Pass force to delete anyway.
Pass { force: true } to override. Rows created before the attribution column existed have no provisionedByTheme, so the guard self-disables for them.
listMetafieldDefinitions
listMetafieldDefinitions(ownerType: string): Promise<MetafieldDefinition[]>
All definitions for an owner, ordered by position ASC then by the serial primary key (a deterministic tie-break — position defaults to 0 for every row, so without it the order reshuffles on every update).
getMetafieldDefinition
getMetafieldDefinition(uuid: string): Promise<MetafieldDefinition | null>
One definition by UUID, or null. Does not throw.
validateMetafields
validateMetafields(ownerType: string, input?: MetaData): Promise<MetaData>
Validate a full values object against an owner's definitions and return the object to persist. Defaults applied, unknown keys dropped, repeater _id keys stripped.
Blank values (undefined, null, '', structurally-empty rich text) are normalised to "not provided" rather than validated — the admin form serializes every field on submit, so without this a single untouched optional date would fail AJV and abort the whole save.
Returns a MetaData object safe to write into meta_data.
Throws (status 400) on the first failure, with a field-scoped message:
"<namespace>.<key>" is required"<namespace>.<key>": <ajv message>
import { validateMetafields } from '@evershop/evershop/lib/metafield';
import { update } from '@evershop/evershop/lib/postgres/query';
const metaData = await validateMetafields('product', {
spec: { care_instructions: 'Machine wash cold' }
});
await update('product')
.given({ meta_data: metaData })
.where('product_id', '=', productId)
.execute(pool);
validateMetafield
validateMetafield(
ownerType: string,
namespace: string,
key: string,
value: unknown
): Promise<unknown>
Validate a single field for partial / out-of-band writes. Returns the cleaned value, or undefined when the value is blank and the field is optional.
Throws (status 400) on No metafield "<namespace>.<key>" on "<ownerType>", on a required-but-blank value, or on an AJV failure.
shapeMetafields
shapeMetafields(
metaData: MetaData,
ownerType: string,
opts: {
audience: 'admin' | 'customer';
namespace?: string;
cache?: DefinitionCache;
}
): Promise<ShapedMetafield[]>
Zip a stored meta_data object with its owner's definitions for output. Every defined field appears in the result, with value: null when unset — so the consumer sees the full schema, not just the populated keys.
| Option | Type | Description |
|---|---|---|
audience | 'admin' | 'customer' | Required. 'customer' drops every definition with visibleToCustomer: false. There is deliberately no "return everything" overload — the audience is mandatory so a caller cannot accidentally leak hidden fields. |
namespace | string | Restrict output to one namespace. |
cache | DefinitionCache | Request-scoped memo. Without it, every resolved field costs a definitions SELECT. |
Returns ShapedMetafield[] — { namespace, key, type, value }.
import { shapeMetafields } from '@evershop/evershop/lib/metafield';
const fields = await shapeMetafields(product.meta_data, 'product', {
audience: 'customer',
cache: context.metafieldDefinitionCache
});
createDefinitionCache
createDefinitionCache(): DefinitionCache
listDefinitionsCached(ownerType: string, cache?: DefinitionCache): Promise<MetafieldDefinition[]>
DefinitionCache is a Map<string, Promise<MetafieldDefinition[]>>. Create one per request and pass it to shapeMetafields; core builds one at both GraphQL context sites and exposes it as context.metafieldDefinitionCache.
It stores promises, not results, so 48 card resolvers all missing an empty cache in the same tick share one in-flight query instead of racing 48 identical SELECTs.
Definitions are runtime-mutable from the admin, so a process-lifetime or TTL cache would need an invalidation story. Per-request is always fresh and needs none. Do not hoist a DefinitionCache to module scope.
import { createDefinitionCache } from '@evershop/evershop/lib/metafield';
const context = {
metafieldDefinitionCache: createDefinitionCache()
};
buildProjection
buildProjection(entries: ManifestMetafieldDefinition[]): MetafieldProjection
Pure function: turn a theme's theme.json metafieldDefinitions[] array into a projection map keyed owner.namespace.key.
interface ProjectedDescriptor {
type: MetafieldType;
isList?: boolean;
visibleToCustomer: boolean;
name: string;
description?: string;
placeholder?: string;
}
type MetafieldProjection = Record<string, ProjectedDescriptor>;
Entries missing ownerType, namespace, key or type are skipped rather than throwing.
The projection is what the storefront <Metafield> component knows about the declared fields, independent of what the database currently holds. It exists because a customer-audience GraphQL response cannot distinguish "unset" from "hidden" from "not defined yet" — all three are simply absent. Without the declaration, a visibleToCustomer: false field's value ?? defaultValue would render its default publicly.
loadThemeMetafieldProjection() is the companion that reads the active theme's manifest synchronously and calls buildProjection. It never throws — a broken manifest returns {} rather than breaking rendering.
provisionThemeMetafields
provisionThemeMetafields(
themeId: string,
entries: ManifestMetafieldDefinition[],
pool: Pool
): Promise<ProvisionResult>
Ensure a theme's declared metafield definitions exist. Idempotent and always safe to re-run — core runs it at theme:active and at every server boot after migrations, in its own transaction.
Per declared entry:
| Situation | Outcome |
|---|---|
No definition with that (ownerType, namespace, key) | seeded — atomic INSERT … ON CONFLICT DO NOTHING, stamped provisioned_by_theme, plus a metafield_definition_created event. |
Exists, immutables (type, isList) match | adopted — attribution claimed only when the row is unowned (first seeder wins). Mutable drift is warned about and kept; nothing is auto-patched. |
| Exists, immutables differ | conflict — reported, never applied, never claimed. updateMetafieldDefinition could not converge them anyway. |
| Attributed to this theme but no longer declared | retired — a read-time report only. The definition stays in place; nothing is deleted. |
Returns ProvisionResult:
interface ProvisionResult {
seeded: string[]; // 'owner.namespace.key' refs
adopted: string[];
retired: string[];
conflicts: Array<{ ref: string; details: ProvisionConflictDetail[] }>;
warnings: ManifestDefinitionIssue[];
errors: ManifestDefinitionIssue[];
skipped: boolean; // true when the attribution column doesn't exist yet
}
skipped: true means the database has not run this core version's migrations yet (for example theme:active on a project whose server has never booted this version). Callers degrade gracefully instead of erroring.
Supporting exports: provisioningAvailable(pool) (does the attribution column exist?), validateManifestMetafieldDefinitions(entries) (strict manifest lint returning { errors, warnings }), classifyIncumbent(declared, incumbent) and refOf({ ownerType, namespace, key }).
required is rejected in theme manifestsSeeding a required definition would make validateMetafields reject every entity save store-wide until values are backfilled, so the manifest validator refuses it.
Owner-scoped service helpers
Each wired owner that has a service barrel exposes the same three functions with the owner baked in.
| Owner | Import path | Functions |
|---|---|---|
product | @evershop/evershop/catalog/services | addProductMetafieldDefinition, setProductMetafields, setProductMetafield |
category | addCategoryMetafieldDefinition, setCategoryMetafields, setCategoryMetafield | |
collection | addCollectionMetafieldDefinition, setCollectionMetafields, setCollectionMetafield | |
order | @evershop/evershop/oms/services | addOrderMetafieldDefinition, setOrderMetafields, setOrderMetafield |
customer | @evershop/evershop/customer/services | addCustomerMetafieldDefinition, setCustomerMetafields, setCustomerMetafield |
Signatures
addProductMetafieldDefinition(
input: Omit<CreateDefinitionInput, 'ownerType'>
): Promise<MetafieldDefinition>
setProductMetafields(
productId: number,
values: MetaData,
connection?: Pool | PoolClient
): Promise<void>
setProductMetafield(
productId: number,
namespace: string,
key: string,
value: unknown,
connection?: Pool | PoolClient
): Promise<void>
The category, collection, order and customer variants are identical apart from the first parameter (categoryId, collectionId, orderId, customerId) and the table they write. connection defaults to the shared pool.
set…Metafieldsis the form-save path: it runsvalidateMetafieldsand writesmeta_datawholesale. Anything not invaluesis erased.set…Metafieldis the out-of-band path (an extension writing a computed value): a targetedjsonb_setmerge that creates the namespace object if missing and leaves every other key alone. A blank value removes the key rather than storing a JSONnull.
import {
addProductMetafieldDefinition,
setProductMetafield
} from '@evershop/evershop/catalog/services';
await addProductMetafieldDefinition({
namespace: 'spec',
key: 'thread_count',
name: 'Thread count',
type: 'integer',
validations: [{ type: 'range', min: 1, max: 2000 }]
});
// Later, from a subscriber — touches only this one key.
await setProductMetafield(productId, 'spec', 'thread_count', 400);
Owners with no service functions
| Owner | Status |
|---|---|
shop | Helpers exist in the source (addShopMetafieldDefinition, getShopMetaData, setShopMetafields, setShopMetafield) but live in modules/base/services/, which is not reachable through the package exports map — only ./base/services/sitemap is exported. Use the owner-agnostic lib/metafield functions with ownerType: 'shop', or the admin REST endpoint. |
blog_post | No owner-scoped service functions exist at all, and there is no ./blog/services entry in the exports map. The blog module folds submitted metafields into meta_data through its own registry processors. Use createMetafieldDefinition / validateMetafields directly with ownerType: 'blog_post' or 'blog_category'. |
blog_category |
Do not go hunting for addBlogPostMetafieldDefinition or an importable addShopMetafieldDefinition — they are not part of the public API.
See Also
- Metafields — Concepts, admin UI and the storage model
- Theme Metafields — Declaring fields in
theme.json - Metafield Definition API — The REST endpoints behind the admin UI