Skip to main content

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';
Definitions are not bootstrap-locked

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>
FieldTypeRequiredDescription
ownerTypestringYesOne of the wired owners (or a custom string).
keystringYesField key, unique per (ownerType, namespace).
namestringYesAdmin-facing label.
typeMetafieldTypeYesData type.
namespacestringNoDefaults to 'custom'.
descriptionstringNoHelp text.
isListbooleanNoRepeatable field. Defaults to false.
requiredbooleanNoDefaults to false.
translatablebooleanNoDefaults to false.
visibleToCustomerbooleanNoDefaults to true. false hides the field from the storefront audience.
validationsValidation[]Nosize / range / regexp / choices.
appearanceRecord<string, unknown>NoUI hints. appearance.placeholder is the single source of a storefront default.
subFieldsFieldDescriptor[]NoOnly for type: 'group'.
positionnumberNoSort order. Defaults to 0.
provisionedByThemestringNoTheme 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):

StatusCondition
400ownerType, key, name or type missing.
400The descriptor does not compile — nesting deeper than 3 levels, a malformed group, an unusable validation.
409A 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:

StatusCondition
404Metafield 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.
400The 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:

StatusCondition
404Metafield definition "<uuid>" not found.
409Theme-provenance guard — see below.
The 409 theme-provenance guard

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.

OptionTypeDescription
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.
namespacestringRestrict output to one namespace.
cacheDefinitionCacheRequest-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.

Request-scoped on purpose

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:

SituationOutcome
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) matchadopted — attribution claimed only when the row is unowned (first seeder wins). Mutable drift is warned about and kept; nothing is auto-patched.
Exists, immutables differconflict — reported, never applied, never claimed. updateMetafieldDefinition could not converge them anyway.
Attributed to this theme but no longer declaredretired — 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 manifests

Seeding 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.

OwnerImport pathFunctions
product@evershop/evershop/catalog/servicesaddProductMetafieldDefinition, setProductMetafields, setProductMetafield
categoryaddCategoryMetafieldDefinition, setCategoryMetafields, setCategoryMetafield
collectionaddCollectionMetafieldDefinition, setCollectionMetafields, setCollectionMetafield
order@evershop/evershop/oms/servicesaddOrderMetafieldDefinition, setOrderMetafields, setOrderMetafield
customer@evershop/evershop/customer/servicesaddCustomerMetafieldDefinition, 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…Metafields is the form-save path: it runs validateMetafields and writes meta_data wholesale. Anything not in values is erased.
  • set…Metafield is the out-of-band path (an extension writing a computed value): a targeted jsonb_set merge that creates the namespace object if missing and leaves every other key alone. A blank value removes the key rather than storing a JSON null.
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

OwnerStatus
shopHelpers 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_postNo 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