@fmaplabs/meta-manifest
v0.9.0
Published
Zero-dependency, zod-style builder for Shopify metaobject definitions, plus a CLI (mm) that syncs them to a store via pull → diff → push.
Downloads
136
Maintainers
Readme
meta-manifest
A zero-dependency, zod-style builder for Shopify metaobject definitions, plus a CLI
(mm / meta-manifest) that keeps a store's definitions in sync with schema declared in code.
Think tento, but scoped to metaobject-definition
schema/migrations rather than a runtime query client (see Roadmap
below).
meta-manifest is not a runtime client for querying metaobject entries — it declares
definitions, validates values against them, and syncs the definitions themselves (create/update
fields) to a store via pull → diff → push. It can also declare seed entries in code and
upsert them on push (see Seed entries) — but never enumerates, queries, or
deletes store data.
Install
npm i -D @fmaplabs/meta-manifest
# or
pnpm add -D @fmaplabs/meta-manifestLibrary usage
Declare a metaobject with defineMetaobject and the m field builders. Implements
Standard Schema.
import { defineMetaobject, m, type Infer } from "@fmaplabs/meta-manifest";
export const Author = defineMetaobject("author", {
name: "Author",
displayName: "name",
access: { storefront: "public_read" },
fields: {
name: m.text({ required: true, max: 120 }),
bio: m.multilineText(),
rating: m.rating({ min: 1, max: 5 }),
},
});
type AuthorValue = Infer<typeof Author.fields>;
Author.type; // "$app:author"
Author.toDefinitionInput(); // MetaobjectDefinitionCreateInput (for metaobjectDefinitionCreate)
Author.parse(fields); // Shopify {key, jsonValue}[] -> typed, validated
Author.encode({ name: "Ursula" }); // typed -> [{ key, value }] for metaobjectUpsertReferences between metaobjects are declared with m.ref(...) / m.list(m.ref(...)).
Use m.mixedRef([...]) (and m.list(m.mixedRef([...]))) for a field that may point at
several metaobject types:
export const Book = defineMetaobject("book", {
name: "Book",
fields: {
title: m.text({ required: true }),
author: m.ref(Author), // → one Author
related: m.list(m.ref(Author)), // → many Authors
feature: m.mixedRef([Author, Publisher]), // → one Author *or* Publisher
},
});m.ref maps to Shopify's metaobject_reference (a metaobject_definition_type
validation); m.mixedRef maps to mixed_reference (a metaobject_definition_types
validation). Targets are referenced by type, so push orders creates so a referenced
definition exists first — and a reference cycle is created two-pass (the
cycle-breaking fields are added by a follow-up update once every member exists).
Pass a thunk (m.ref(() => Book), m.mixedRef([() => Book])) for forward/circular
references.
Configuration options
Beyond fields, a metaobject definition accepts these options — all optional, all
reconciled by diff/push against a live store:
export const Author = defineMetaobject("author", {
name: "Author",
displayName: "name", // optional; omit → Shopify auto-generates
scope: "merchant", // optional; overrides config.scope for this metaobject
access: {
admin: "merchant_read_write", // "merchant_read" | "merchant_read_write" (app scope only)
storefront: "public_read", // "none" | "public_read"
customerAccount: "read", // "none" | "read"
},
capabilities: {
publishable: true, // active/draft status
translatable: true, // translations
renderable: { metaTitleKey: "name", metaDescriptionKey: "bio" }, // SEO metadata; also accepts `true`
onlineStore: { urlHandle: "authors", createRedirects: true }, // publish entries as web pages
},
fields: {
name: m.text({ required: true, filterable: true }), // filterable → "use as filter" in the admin
bio: m.multilineText(),
},
});| Option | Maps to | Notes |
| --- | --- | --- |
| scope (config or per-metaobject) | app ($app:<handle>) vs merchant (<handle>) type | default "app"; resolved at sync time — Author.type stays "$app:author" |
| merchantEditable (config) | access.admin default | false → merchant_read, true → merchant_read_write (app scope only) |
| access.admin | access.admin | per-metaobject override; invalid on merchant scope |
| access.storefront | access.storefront | Storefront API access |
| access.customerAccount | access.customerAccount | Customer Account API access (none | read) |
| capabilities.publishable | capabilities.publishable | active/draft status |
| capabilities.translatable | capabilities.translatable | translations |
| capabilities.renderable | capabilities.renderable | true or { metaTitleKey?, metaDescriptionKey? } (SEO) |
| capabilities.onlineStore | capabilities.onlineStore | publish as web pages; GraphQL-only (not shopify.app.toml) |
| displayName | displayNameKey | omit to let Shopify auto-generate |
| field filterable | field capabilities.adminFilterable | expose the field as an admin filter |
For the full pull → diff → push sync model (how local schema and a live store are
reconciled, destructive-change gating, dependency ordering, error handling), see
docs/SYNC.md.
Declaring schemas across multiple files
The schema module doesn't have to hold every definition. Declare each metaobject in its
own file as the module's default export, import them into the main schema module (the
file schema points at in the config), and list them in the schemas array. That array
is the manifest diff/push read — a definition file that isn't imported there is
invisible to the CLI.
// src/metaobjects/author.ts
import { defineMetaobject, m } from "@fmaplabs/meta-manifest";
export default defineMetaobject("author", {
name: "Author",
fields: {
name: m.text({ required: true, max: 120 }),
bio: m.multilineText(),
},
});// src/metaobjects/book.ts
import { defineMetaobject, m } from "@fmaplabs/meta-manifest";
import Author from "./author";
export default defineMetaobject("book", {
name: "Book",
fields: {
title: m.text({ required: true }),
author: m.ref(Author),
},
});// src/schema.ts — the module `schema` in the config points at
import author from "./metaobjects/author";
import book from "./metaobjects/book";
export const schemas = [author, book];mm init scaffolds this layout. Cross-file references work exactly like same-file ones —
including thunks for forward/circular references (m.ref(() => Book)). The loader
validates every element of schemas: a file that forgets its export default imports as
undefined and fails fast naming the offending index, and two files declaring the same
metaobject type are rejected as a duplicate. One caveat: mm pull codegens a single
file, so re-pulling overwrites the main schema module with all definitions inlined.
Seed entries
Beyond definitions, specific metaobject entries (data instances) can be declared by handle
and kept in sync. Declare them with defineEntries in a module that exports an entries array,
and point entries in the config at it:
// src/entries.ts
import { defineEntries, entryRef } from "@fmaplabs/meta-manifest";
import { Author, Book } from "./schema";
export const bookEntries = defineEntries(Book, {
persuasion: { title: "Persuasion" },
});
export const authorEntries = defineEntries(Author, {
"jane-austen": {
name: "Jane Austen",
favoriteBook: entryRef(Book, "persuasion"), // reference another declared entry
portrait: "gid://shopify/MediaImage/123", // raw GIDs pass through untouched
},
}, { status: "active" }); // optional publishable status for the set
export const entries = [bookEntries, authorEntries];Entry sets can be split across files the same way as
schemas — each file default-exports one
defineEntries(...) set, and the main entries module imports them into the entries array:
// src/entries/authors.ts
import { defineEntries } from "@fmaplabs/meta-manifest";
import Author from "../metaobjects/author";
export default defineEntries(Author, {
"jane-austen": { name: "Jane Austen" },
"ursula-le-guin": { name: "Ursula K. Le Guin" },
});// src/entries.ts — the module `entries` in the config points at
import authorEntries from "./entries/authors";
import bookEntries from "./entries/books";
export const entries = [authorEntries, bookEntries];Entry values are typed against the schema's InferInput, validated at plan time (before any
network call), and upserted via metaobjectUpsert when mm push runs — after definitions.
The model is upsert-only seed data:
- Only declared
(type, handle)pairs are ever touched. Merchant-created entries, and fields you don't declare on a declared entry, are never compared, written, or deleted. entryRef(Target, "handle")references another declared entry; push creates referenced entries first and resolves the reference to its GID. Reference cycles (and self-references) are handled with a two-pass upsert. One caveat: a required reference field inside an entry cycle may fail the first pass with a Shopify userError.- To reference products, files, or entries meta-manifest doesn't manage, use a raw
gid://shopify/...string. mm diffpreviews entry changes the same way it previews definition changes.
CLI
The CLI drives sync against a real store using an Admin API access token. For a
step-by-step walk-through (install → token → init → pull/diff/push, with
example output and CI usage), see the CLI quick start & usage guide.
Config
meta-manifest.config.ts (safe to commit — the token comes from the environment):
import { defineConfig } from "@fmaplabs/meta-manifest";
export default defineConfig({
shop: "my-store.myshopify.com",
accessToken: process.env.SHOPIFY_ADMIN_TOKEN!,
apiVersion: "2026-07", // optional; defaults to DEFAULT_API_VERSION
schema: "./src/schema.ts", // where `pull` writes, `diff`/`push` read
entries: "./src/entries.ts", // optional; seed entries to upsert on push
scope: "app", // optional; "app" (default) | "merchant" — applies to all metaobjects
merchantEditable: false, // optional; default admin access for app-scoped metaobjects
});Set SHOPIFY_ADMIN_TOKEN before running pull, diff, or push — either export it into your
shell or put it in a .env file in the project root, which the CLI loads automatically (real
environment variables take precedence). The token needs the read_metaobject_definitions scope for
pull/diff, and write_metaobject_definitions (which implies read) for push. When entries
is configured, it additionally needs read_metaobjects for diff and write_metaobjects for
push.
Commands
| Command | Behavior | Exit |
|----------|----------|------|
| mm init | Scaffold meta-manifest.config.ts + a starter schema (src/schema.ts aggregating src/metaobjects/author.ts). No network. | 0 / 1 |
| mm pull | Enumerate the store's app-owned metaobject definitions and codegen schema.ts (tento-style — writes/overwrites the schema source file). | 0 / 1 |
| mm diff | Load schema.ts, compare it against the store, and print the plan (definitions, then declared entries when configured). Read-only. | 0 / 1 |
| mm push | Diff, then apply: topologically ordered (referenced types created first) and destructive-gated — removeField/changeFieldType are skipped unless you pass --allow-destructive. Declared entries are upserted after definitions. | 0 / 1 / 2 |
npx mm init # scaffold config + schema
npx mm pull # bootstrap schema.ts from an existing store
npx mm diff # preview what push would do
npx mm push # apply non-destructive changes
npx mm push --allow-destructive # also apply field removals/type changes
npx mm pull --force # overwrite an existing schema.ts without the warning
npx mm diff --config ./staging.config.ts # use a non-default config fileFlags
--config <path>— use a non-default config file instead ofmeta-manifest.config.ts.--allow-destructive— apply destructive changes (removeField/changeFieldType) on push.--force— overwrite the schema file onpullwithout the "overwriting" warning.
mm push exits 2 if any operation failed or was blocked (e.g. a reference cycle among the
definitions being created in that push — so CI can detect a partial failure), 1 on a
config/transport error, and 0 otherwise — including when destructive ops were skipped.
Roadmap: runtime query client
Definitions (schema sync) and declared seed entries are covered. A runtime client for querying/enumerating metaobject entries — the tento-style query API — is not implemented yet and is tracked as a follow-up.
