Data Schemas
A data schema is the shape of a content type — what fields a person has, what a product looks like. You write it once, and it does three jobs at the same time: uniweb validate checks your data before it ships, a fetch of the type reaches the components that declare it, and the editor renders a form authors fill in. Define the shape once instead of re-describing it in a validator, a fetch handler, and a form.
A schema describes a record; it never changes one. A record reaches your component as it is — a field it lacks is absent — so a schema file declares no default: (the build refuses one). What an absent field renders as is your component's choice.
This guide is about the schema definition — authoring one, sharing it across projects, and registering it as a reusable content type. For how data is fetched and delivered to components at runtime, see Working with Data.
What you get
| What you write | What you get |
|---|---|
| The shape, once, in a schema file | validate on your data, and a form in the editor — from one definition |
A reference by name (@/product) | The reference is a name, not a path — move or rename the implementation freely |
Shared schemas in one place (@org/schemas) | Fix person once; every foundation that references it picks up the change |
Authoring a schema
A schema is a file in your foundation's foundation/schemas/ folder. The simplest form is a flat list of fields:
# foundation/schemas/product.yml
name: product
description: A product offered by the client.
fields:
name: { type: string, required: true }
tagline: { type: string }
price: { type: number }
image: { type: image }
description: { type: markdown }
url: { type: url }
featured: { type: boolean }The types are plain and content-oriented — string, text, markdown, number, boolean, date, datetime, image, url, email, richtext, plus structural object (also spelled group), array, and ref (a reference to another schema). You write the word that fits the content; the framework folds the friendly names to canonical kinds (markdown is a text field carrying that rich-content format, richtext is a ProseMirror json document, image is a file, number is a decimal).
Describing the schema itself
Beside its content, a schema can carry a few keys about the model as a whole. All are optional:
name: session # the schema's name — this is its identity
label: Session # a display name, for people
plural: Sessions # what many entries are called
icon: lu-presentation # the schema's icon — a framework icon name
description: A conference talk.
source_locale: en # the language the inline text is written in
linkable: true # may other schemas reference this one? (default: yes)
grantable: true # may an entry be shared, one at a time? (default: yes)label, plural and description are plain strings in the source language — translations live in locales/, so label: { en: 'Session' } is a different shape and is rejected rather than quietly accepted. plural is written out because it cannot be worked out from the label: a Person schema's entries are People.
icon names the schema's icon with a framework icon name, written family-name — lu-user is the icon user of the Lucide family lu, the same spelling content uses for an icon (). It is one value for every language. A name that is not family-name stops the build.
linkable controls whether this schema's entries may be referenced — by another schema's ref field, and, on a backend, as a site's records. It is true unless you write linkable: false, with or without a brief: section. The brief is what a reference shows, so a reference to an entry of a schema without one has no card to show.
grantable controls whether, on a backend, one entry can be shared with a person or a team — to read it, edit it or administer it. It is true unless you write grantable: false, which keeps every entry of the schema from being shared that way. A backend may refuse to turn it off while an entry of the schema is shared.
There is no key for who may create entries. When a backend stores your content, anyone signed in may create entries of any schema. What protects content is the entry itself: who may see or edit an entry is decided by the entry — its owner, and what they share — never by who may create one. creatable_by is no longer accepted: a schema carrying it is refused, so a restriction you believe you declared is never silently absent.
An unrecognised key here is reported as a warning and not carried, so a typo like
labletells you rather than leaving you with a key that does nothing.
A section type names the schema in its meta.js, under the key it reads:
// foundation/sections/Products/meta.js
export default {
title: 'Products',
data: { products: '@/product' }, // content.data.products, shaped by @/product
}Your component then reads content.data.products — a list of products, each as its record holds it, so a field a product lacks is absent and the component decides what to show instead (product.price ?? 0). The declaration is what the section receives: a component that doesn't declare products receives no products. (The full data: reference — inline field maps and the editor "rich form" — is in Component Metadata → Data.)
For content with more structure than a flat record — say a profile with a bio plus a list of publications — a schema declares named sections: instead of fields:. Each section is one record by default, or a repeating list (many: true):
# foundation/schemas/person.yml
name: person
sections:
identity:
brief: true # single is the default; the card shown when referenced
fields:
name: { type: string, required: true }
role: { type: string }
publications:
many: true # a repeating list of records
fields:
title: { type: string }
year: { type: number }The flat fields: form is the common case; reach for sections: only when a single record genuinely can't express the content.
The fields: form is one section. It is sections: with a single section named brief, marked brief: true — the whole record is its own card. When a schema outgrows it, write that section out and add the others beside it:
# foundation/schemas/product.yml — what `fields:` was, plus a section
name: product
sections:
brief:
brief: true
fields:
name: { type: string, required: true }
price: { type: number }
specs:
fields:
weight: { type: string }Keep the name brief if records of the schema are already stored in a backend: a section's name is where its data is kept. The record files change with the schema — a schema of more than one section is written by section, so each record's fields move under brief: (Entity Content); the build names every field that is left at the top. Naming a schema's brief section brief is a good habit in any schema: whoever reads a record knows at a glance which section is its card.
Append-only sections
A multi section can be marked insert-only with append_only: true — records may be added, but never edited or deleted:
# foundation/schemas/membership.yml
name: membership
sections:
identity:
brief: true
fields:
name: { type: string, required: true }
activity:
many: true
append_only: true # records accumulate; existing ones are immutable
fields:
at: { type: datetime }
event: { type: string }Once you register the schema, this rule is enforced wherever entities of the type are written — appends are accepted, but changing or removing an existing record is refused. There's no "replace the whole section" shortcut either: re-submitting records adds new ones rather than overwriting what's already there. Because the rule lives in the content type rather than in a form, it holds for every writer, not just the editor UI. That makes an append-only section tamper-evident: the accumulated history stands on its own. Reach for it for activity logs, submissions, audit trails — anything meant to accumulate and never be rewritten.
append_only is only valid where there are many records to append to — a many: true section, or a list of records authored as a field (activity: { type: object, many: true, append_only: true }). On a single record it's rejected rather than ignored. Records in files have no write step, so it has no effect until the type is registered.
When the content is a list
Some content isn't a record with parts — it is a list. A navigation menu is a list of items; a form is a list of controls. Declare that as one many: true section and nothing else, and the content is a bare list with no wrapping key:
# foundation/schemas/menu.yml
name: menu
sections:
items:
many: true
fields:
label: { type: string, required: true }
href: { type: string, translatable: false }```yaml:menu
- label: Home
href: /
- label: Docs
href: /docs
```No brief: here, and that's right — there's no single record to be the card, so the type just isn't referenceable from another schema. uniweb validate checks each record and names its index ([1].label), and defaults are applied per entry. This is how @std/nav and @std/form are built, and both declare linkable: false as well.
One limit: it must be exactly one section. Two many sections with no single one leaves "which one is the value?" unanswerable, so nothing is checked.
A list-shaped block carries no title of its own, and that's deliberate. A heading and an intro are content — the author writes them as markdown in the section, and your component reads them from content.title and content.paragraphs exactly as it would for any other section. The block carries only the structured part.
It's worth stating because the alternative is tempting and wrong: putting a title: inside the block gives the section two titles, one editable as ordinary content and one buried in a data block, with nothing to say which wins. Keep the prose in the prose.
Tree sections
A many: true section can be marked tree: true, letting its records nest under each other — a chapter tree, a category hierarchy, a threaded discussion:
# foundation/schemas/handbook.yml
name: handbook
sections:
identity:
brief: true
fields:
title: { type: string, required: true }
chapters:
many: true
tree: true # a chapter can sit under another chapter
fields:
title: { type: string }
body: richtextAuthors nest records under a reserved children: key — you declare one record's fields, and children holds more of the same:
- title: Getting started
children:
- title: Installing
- title: Your first pageThe parent/child link is managed for you — there's no field to declare for it and no ID to wire up. uniweb validate descends into children to any depth and names the full path ([1].children[0].title), so a bad entry deep in a tree is findable.
This is what separates tree: from a subsection. A subsection nests one named section inside another — a course has modules, a module has lessons, a fixed shape you spell out in the schema. tree: instead lets records of a single section nest under one another, so the shape is decided by the author as they write, not by you as you model.
tree: true is only valid where there are many records to nest — a many: true section, nested sections included, or a list of records authored as a field (chapters: { type: object, many: true, tree: true }). On a single record, or on a list of plain values, it's rejected rather than ignored. nestable: true is accepted as a lower-level spelling of the same flag.
The sort axis: sort_date
A schema can name which date field its records sort by — the axis a feed, an archive, or a "latest first" listing orders on:
# foundation/schemas/post.yml
name: post
sort_date: published_on # names a date field below
fields:
title: { type: string, required: true }
published_on: { type: date }
body: richtextsort_date is a schema-level key, and its value is the name of a date field — not true/false, and not something you put on the field itself. With the flat fields: form the whole field set is the record's card, so any date field there can be the axis. With the sections: form, name a field in the brief section; a schema with no brief has no sort axis. It takes effect once the schema is registered. (sortDate is accepted as an alias.)
Field types and formats
The friendly type you write folds to a small set of canonical kinds the framework stores and ships. Write the word that fits the content — the framework normalizes it:
| You write | Canonical kind | Holds |
|---|---|---|
string | string | A short, single-line value |
text | text | Long-form text (see format below) |
number | decimal | A number |
integer | int | A whole number |
boolean | bool | true / false |
date, datetime | date, datetime | An ISO-8601 date — a day, or a month or a year with format — / a timestamp |
image | file | A path or URL to a file |
url, email | string (+ format) | A validated string |
markdown | text (+ format) | A rich-content body (a source string) |
richtext | json (+ format: richtext) | A rich document edited in the visual app |
json | json | An opaque structured value (see format) |
object, group | object | A nested record — declare its fields:. group is the friendlier spelling |
ref | ref | A reference to another schema — write { ref: '@/person' } |
You can always write the canonical kind directly; the friendly names just save you the folding.
References. A ref field points at a record of another schema. In a record's file, write the name of the record it points at — its file's name — so speaker: ada points at records/speaker/ada.yml; a many: true reference is a list of names. A component receives the record it points at reduced to its brief, as { entity, brief } — so talk.speaker.brief.name — with entity the record's id where it has one (Entity Content). With a backend, uniweb push sends each reference to the record it names — in the same push when that record is new too — and uniweb pull writes the name back.
Lists use many: true. Any field or section becomes a list by adding many: true — { type: string, many: true } (a list of strings), { ref: '@/course', many: true } (a list of references), or a many: true section (a repeating list of records). List-level flags like required ride on the list; the type describes each item.
A reference that opens its entry: readable_with
On a backend, a reference can carry access. A ref field that declares readable_with opens the entry holding it — to read, never to change — to whoever holds that level on the entry the reference points to:
# foundation/schemas/submission.yml
name: submission
fields:
title: { type: string }
course: { ref: '@/course', readable_with: edit } # read by whoever may edit the courseA submission's course declaring edit is read by the course's staff and by none of its students; a lesson's course declaring read is read by everyone who may read the course. The level is read, edit or admin, and only a reference — a ref, or a list of them — can declare it; any other field is refused. It reaches one step: the course's own references open nothing further. The link is made from the entry's side, by setting the field. And a backend refuses adding it to a field entries already hold a value in, so references written as plain ones never become access later.
A reference a purchase travels along: sold_with
On a backend that sells access to records, a reference can say that buying the entry holding it also buys what it names. Declare sold_with: true on it:
# foundation/schemas/course.yml
name: course
fields:
title: { type: string }
lessons: { ref: '@/lesson', many: true, sold_with: true } # a buyer of the course reads its lessons
mentor: { ref: '@/person' } # buying the course buys nothing hereA buyer of a course reads, through it, the lessons its lessons field names — each element of a list is its own link — and onward along those entries' own sold_with fields, up to three steps (course → module → lesson → quiz). A reference without it carries nothing. It is a boolean, and only a reference — a ref, or a list of them — can declare it; any other field is refused. false says what leaving it out says. Unlike readable_with, it may be added to or removed from a field entries already fill.
Every list has an element type: many: true takes it from the field itself, and the lower-level array + items: states it explicitly. Writing type: array with no items: leaves it genuinely unknown, and a registered schema records the elements as opaque rather than guessing at them.
required holds on a list of values ({ type: string, many: true, required: true }) and on a list of references. It does not hold on a list of records or on a nested object — both become sections once the schema is registered, and required binds the record that is written; it cannot force a record to exist. Put the flag on a field inside the record, and see Constraints for "don't let this become empty".
Constraints
A nested record and a list of records become sections when a schema is registered, and a section can carry rules a single field can't express. Declare them with constraints: — on the section in the sections: form, beside fields: in a schema written with fields: (they rule its one section), or on a nested-record field itself:
fields:
authors:
type: object
many: true
constraints:
- { kind: min_items, value: 1 }
fields:
name: { type: string, required: true }min_items is the common one, and its scope is narrower than the name suggests:
- A delete floor, not a fill requirement. It refuses a delete that would take the section below N. Nothing forces an author to populate it in the first place. On a single-record section, read it as "undeletable once created".
- A write guarantee, never a render guarantee. Your component still handles an empty list — the same schema can be rendered by a foundation that never saw the constraint, so content and code stay independent axes.
A rule names fields of its section, so it is never declared on a plain value: a schema that puts constraints on one is refused, pointing at the section's constraints:. A plain value narrows with enum, format and its own rules instead. Constraints take effect once the schema is registered — records in files have no write step.
Rich content: format
A format marks a field as carrying rich content. It is type-bound — the framework rejects a mismatch at build time:
format | Valid on | Use it for |
|---|---|---|
markdown | text | A markdown body that round-trips as plain source |
richtext | json | A rich document edited through a structured editor |
scene | json | A visual scene composition (rendered by @uniweb/scene) |
fields:
summary: { type: markdown } # a source body (round-trips as markdown text)
body: richtext # a rich document, edited in the visual app (json + format: richtext)The friendly aliases set these for you — type: markdown is exactly type: text, format: markdown, and type: richtext is exactly type: json, format: richtext. Use richtext for a rich body edited in the visual app — the common case; it's the editor's native, lossless document. Use markdown for a source body authored as text (file-based projects, or content you want to keep readable as raw source). Don't reach for markdown just because it's the familiar word — if it'll be edited visually, you want richtext.
There is no html type or format. Content is never markup — a component decides how content renders — so a schema declaring one is refused, naming richtext and markdown. And format: prosemirror, the earlier name of format: richtext, is refused naming its successor.
Other formats check a value rather than mark it — email and url on string and text, currency on string — and on a date the format is its precision (A field's rules). A format the field's kind does not take is refused when the schema is built.
format: icon on a string says the value is an icon name, written the way content names an icon: lu-flame is the icon flame of the family lu, as in . The value is kept as written — nothing checks it — and, like any string with a format, it is one value for every language:
fields:
icon: { type: string, format: icon } # like lu-flameTranslatable fields: translatable
Text and rich-content fields are translatable by default — the framework keeps one value per locale. Set translatable: false to opt a field out (an ID, a slug, a machine code that's the same in every language):
fields:
title: { type: string } # translatable by default
body: { type: text, format: markdown } # translatable by default
sku: { type: string, translatable: false } # one value across all localesYou author a translatable field as a plain value in your source locale; translations live in the locales/ folder. See Internationalization.
Picklists: enum and options
Two ways to constrain a field to a set of choices:
fields:
status: { type: string, enum: [draft, published, archived] } # inline list
country: { options: '@/lists/countries' } # a list kept as content
province: { options: '@/lists/countries/subdivisions' } # a nested list, by its pathenum:— an inline list of allowed values. Best for a short, fixed set. An entry can also be{ value, label }—{ value: published, label: Live }— to give an editor a label to show; a record stores thevalue.options:— the section of a lists schema that holds the choices: the schema's ref, then the section's name — a nested section by its path. Best when the choices are a managed list, reused across fields or foundations, that people curate as content. A field stores the chosen item. Keep the lists a project shares in one lists schema, a section per list; a schema whose top holds a single list may be named alone (options: '@/countries'), and one with several is refused until the field names its section.
An inline array always belongs on enum:; options: always names a schema. A field cannot take its choices from a section of its own schema — keep the list in a schema of its own. When the schema is built, an options: that names no section of the schema, or a section that holds only other sections, is refused, naming the schema's lists.
A field's rules
A field can carry the rules a backend checks on every write, as its own keys:
fields:
title: { type: string, max_length: 250 } # characters
age: { type: integer, min: 0, max: 150 }
photo: { type: image, mime_types: ['image/*'], max_size: 2000000 } # bytes
fee: { type: string, format: currency } # an ISO 4217 code, like CAD
started: { type: date, format: year-month } # holds '2024-05'
year: { type: date, format: year } # holds '2024'
code: { type: string, pattern: '[A-Z]{2}[0-9]{4}' } # the whole value, like AB1234| Rule | On | Keeps the value to |
|---|---|---|
max_length | string, text | that many characters — each language of a translatable value on its own |
min, max | integer, number | that range |
mime_types, max_size | image, file | those types (image/*, application/pdf) and that many bytes |
format: year-month, format: year | date | a month or a year instead of a day |
format: email, url, currency | string — email and url on text too | a valid value of that kind |
pattern | string | a regular expression the whole value matches |
A date's value is text at every precision. YAML reads an unquoted 2024 as a number, so a year is written year: '2024'; a month, 2024-05, needs no quotes. A day written short, 2024-5-3, is a day too, and a backend stores it in full, 2024-05-03.
A url is a web address: http:// or https://, or one written without a scheme that starts with a host name — example.com/cv — which a backend stores as https://example.com/cv. A path (/about) or another scheme (mailto:, ftp:) is not one; a link inside your own site is a string. It is also one a browser can open: a port above 65535, an unclosed [, or a character no host name may hold — <, or a space written %20 — makes it not one.
A pattern matches the whole value: [0-9]{4} is four digits, not four digits somewhere in the text. On a url it matches the address as stored, with https:// in front. It is written in the syntax JavaScript and a backend read the same way, so what they read differently is refused when the schema is built, each by name: look-around ((?=…), (?<!…)), back-references (\1), named groups, flags ((?i) — write [Aa]), Unicode property classes (\p{L}) and POSIX ones ([[:alpha:]]). Inside a class, ( ) [ ] { } / | and a - that is not part of a range are escaped: [A-Za-z0-9_\-]. A pattern checks a value only when there is one — an empty value is required's concern.
A field takes only the keys this page describes. One it does not — maxLength (it is max_length), placeholder, min_length — is refused by name when the schema is built, rather than dropped. How a field is edited — a widget, a placeholder — belongs to the component that edits it, and a field's guidance for the person filling it is its description: a schema's field declares no help.
What uniweb validate checks
A schema only earns its keep where something runs it, so it's worth knowing exactly where that happens. uniweb validate walks your site and checks every data input it can pair with a schema:
| Where the data lives | Checked against |
|---|---|
Records a query delivers (query: articles) | The schema of the key they fill in the section's meta.js — each record, or all of them as one list when that schema's root is a list |
Every record file in records/ | The schema its folder names (records/member/ → @/member) — whether or not a section reads it, since a push sends every record |
A tagged data block (```yaml:form) | The schema the section declares for that tag, whether the value is a record or a list |
A concept block (```md:faq) | Nothing — its structure comes from the markdown itself, one item per heading |
A violation fails uniweb validate (exit 1); --lax reports without failing. uniweb push and uniweb publish run the same check and stop before sending anything, because the backend checks your records against the same schemas; uniweb deploy to a static host warns and carries on.
A record file is checked as it is written: flat for a schema of one section, by section for any other — each section under its own key, a list section as a list of records. A field written at the top of a record of a schema with more than one section is reported, naming the section it belongs in:
# records/course/rust.yml — written by section
identity: { title: "Rust 101" }
modules:
- title: "Getting Started"The records a query compiles are checked against their schema too, each section where the schema puts it. A component receives each one as its brief, or whole (Entity Content → What a component receives).
Values are checked by their type and their field's rules as well — a date must be a real YYYY-MM-DD day, or a month YYYY-MM or a year YYYY when its format says so, written as text; a datetime a day and a time; a url a web address; and a value over its max_length, outside its min/max, not matching its pattern, a currency that is not a three-letter code, or a file of a type or size its mime_types/max_size exclude, is reported (A field's rules).
One thing is reported as deferred rather than checked, for an honest reason — the data isn't there to look at: an external query (url:), which isn't fetched at build time. A schema whose root is a list is checked as the list: the records a query delivers, or the list a record file holds under its section's key.
An inline schema in a section's data: is reported too, rather than guessed at. A reference must name a record of its schema: one that names none is reported, and a push refuses it. An options value is not checked against the list it names.
What is never checked, by design: a visitor's answers to a form, an API response shape, or anything else that only exists at runtime. validate is a pre-ship gate over content you can see, not a runtime guard — the runtime stays tolerant, applying defaults and ignoring the rest.
Three namespaces
A schema reference is a name in one of three namespaces. The prefix says where the definition lives, and who owns it:
| Ref | Means | Lives in |
|---|---|---|
@/product | Your own — a schema this foundation defines | foundation/schemas/product.{yml,yaml,js,json} |
@std/person | A shared standard — common types anyone can use | the @uniweb/schemas package |
@acme/product | An org's shared schema | that org's @acme/schemas package |
@/ is the self namespace — "this foundation's own." Because you never write your own org name in your source, @/-refs stay portable: the same foundation can be registered under any scope. The standards (@std/person, @std/event, @std/article, @std/project, …) cover the shapes most sites need — reach for one before inventing your own, the same way you'd pull a well-known type off the shelf rather than redefining it.
Add @uniweb/schemas as a dependency when you reference any @std/<name>. Full reference, including how @org/<name> resolves: Component Metadata → Data.
Sharing schemas across projects
Build more than one foundation — a few brands, a client's product line — and they want the same shapes: a person, a project, a product. You don't copy the schema into each foundation. Define it once and reference it, two ways:
A schema package — put the schemas in an
@org/schemaspackage (a workspace package, or one you register) and reference them as@org/<name>. Each foundation lists the package as a dependency.A routed directory — keep the schemas in a plain folder anywhere on disk and point each foundation's
schemas.config.jsat it. No package, no install:// foundation/schemas.config.js export default { '@acme': '../shared/acme-schemas' }@acme/personthen resolves to../shared/acme-schemas/person.{js,yml}. The config is plain JS, so the path can be relative, absolute, or read from an environment variable — useful when one schema repo is shared across many client workspaces on your machine. Reference: Component Metadata → Routing a scope.
Either way the payoff is a single source of truth: fix person once and every foundation that references it picks up the change — and uniweb validate checks each foundation's data against the same definition. A schema ref's @acme names an organization in the Uniweb registry, not an npm scope — the two look alike and aren't connected. For which delivery to pick, how a git repo works as a source, and when registering is required, see Schemas in Practice.
The package form is worth a second look here, because it's also what you register (next section). An @org/schemas package is an ordinary workspace package whose job is to hold schemas:
acme-schemas/ # the @acme/schemas package
├── package.json # { "name": "@acme/schemas" }
└── schemas/
├── product.yml
└── review.ymlAny foundation that lists @acme/schemas as a dependency can bind @acme/product. One definition, every foundation in the org — and uniweb validate checks each foundation's data against the same source of truth. This is exactly how the standards work: @std/* is the centrally-maintained @uniweb/schemas package.
Registering schemas
Everything above works offline, against local files — authoring, validating, and rendering records from files need no account and no network. Registering is the step that turns a schema into a reusable content type in the Uniweb registry: authors can then create and manage entities of that type (in the editor, through the form your schema describes), and any foundation can reference it by name.
Registering is a platform command, so authenticate first:
uniweb loginA foundation and the schemas it defines
uniweb register from a foundation submits the foundation together with the data schemas it owns:
cd foundation
uniweb register --scope @acmeThe scope becomes part of the foundation's name — register writes name: '@acme/<name>' into its main.js — so later runs need no flag, and the foundation and its schemas always register under the same organization.
It submits the schemas you own: every schema file in your foundation's schemas/ folder, resolved into your scope (@/product → @acme/product) — whether or not a section binds it, so a type only your app uses (a lesson, a quiz, a learner's progress) registers too. Placing the file in the folder is what makes it yours; a file whose name starts with _ is a draft and is left out. Schemas you merely reference from another scope are named, not re-submitted: a foundation that renders @std/person and @std/event ships neither definition — it names them, and they resolve to the live standards already in the registry.
That split matters in practice. You don't re-upload the standards every publish (wasteful), and you don't need membership in the @std org to reference its schemas — registration authorizes against the artifacts you publish (@acme/* and the foundation), not against what they reference. The @/ prefix is the signal for "mine, publish it"; every concrete scope (@std/x, another org's @org/x) is a reference by name.
A schema repo on its own
You don't need a foundation to register schemas. Point uniweb register at a schemas-only package — the @org/schemas repo you maintain — and it registers the schemas directly, no foundation involved:
cd acme-schemas # the @acme/schemas package
uniweb register --scope @acmeregister detects a schemas-only package (one named @org/schemas, or any bare schemas/*.yml folder) and submits just its data schemas. This is exactly how the standards under @std are registered — now under your own org. So the @acme/schemas package earns its keep twice: every foundation references @acme/product and resolves it locally during development, and uniweb register registers those same definitions so they exist as managed types. Define once, develop offline, register.
A few practical notes:
- Set
"uniweb": { "scope": "@acme" }in the package'spackage.jsonand you can drop--scopeon every run. - You register under a scope you belong to — membership over the declared scope is what authorizes the registration. (That's why you can register
@acmebut not@std.) - Re-registering an unchanged schema is a no-op, so re-running
registeron a repo you haven't edited costs nothing. - A backend that holds records of a schema accepts a change that keeps them valid — a label or description reworded, a field or a section added, a value added to a field's options — and refuses one that would invalidate or lose what they hold: a field's type changed, a field removed, an option some record uses dropped, a field made required that some record leaves empty. The refusal names the change and how many records it breaks; add the new shape beside what records hold, or publish it under a new name (
@acme/product-v2).
See Also
- Schemas in Practice — where a schema file lives, how a second project consumes it, and when registering is actually required. Read this if
@acme/personwon't resolve. - Designing Data Schemas — the modeling decisions behind a set of related types: sections, subsections, embed vs reference, and relationship (edge) attributes, with a worked LMS example.
- Working with Data — How a query's records reach your components at runtime, and
uniweb validate. - Component Metadata → Data — The
data:field reference: named refs, inline field maps, and the editor rich-form. - Data Schemas as Contracts — Why one schema serves validation, editor UI, and delivery — and how the registry makes content types shareable.
- Registering a Foundation — Publishing a foundation as a catalog product.
- CLI Commands — Full command reference.