Schema
The Schema class and the schema file format: load schemas by name, list them, inspect their models, and the field reference for the schema file.
A Schema is the named, versioned container for your models. Most apps define it in a JSON file and pass it to the client as config.schema. The Schema class is how you load that schema in code, list the schemas the SDK knows about, and read their models. Your first schema walks through writing one; this page is the API and file-format reference.
import { Schema } from '@seedprotocol/sdk'
import type { SchemaFileFormat, SchemaFileInput } from '@seedprotocol/sdk'Call client.init() (or render SeedClientGate) before using Schema. See Client and configuration. Schema is exported from the main entry only, not from @seedprotocol/sdk/node.
Schema.create
Loads the schema with this name, or creates an empty one if none exists. Despite the name, this is the usual way to get a schema you defined in seed.schema.json.
// Waits for the schema to load (the default)
static create(
schemaName: string,
options?: { waitForReady?: true; readyTimeout?: number; hold?: boolean },
): Promise<Schema>
// Returns the instance immediately, still loading
static create(schemaName: string, options: { waitForReady: false; hold?: boolean }): SchemaParameters
| Name | Type | Default | Description |
|---|---|---|---|
schemaName | string | required | The schema's metadata.name, for example 'blog'. Throws Schema name is required if empty. |
options.waitForReady | boolean | true | Wait until the schema and its models have loaded before resolving. |
options.readyTimeout | number | 15000 | How long to wait, in milliseconds. The Promise rejects if the schema doesn't load in time. |
options.hold | boolean | false | Take a cache hold, released with unload(). The schema stays cached without one. A call that rejects gives its hold back. |
Returns
A Promise<Schema> by default, or a Schema when you pass { waitForReady: false }. Instances are cached by name: calling create again with the same name returns the same instance.
Example
const blog = await Schema.create('blog')
console.log(blog.schemaName) // 'blog'
console.log(blog.models.map((model) => model.name)) // ['Post', 'Author']If no schema has this name, create makes a new, empty draft schema (version 1) in the local database. To add models to it, use Model.create.
A schema created this way exists only in the local database until you call saveNewVersion(). For anything you plan to keep, define the schema in seed.schema.json. To look a schema up without creating one, use Schema.find({ schemaName }).
Instance properties
| Property | Type | Description |
|---|---|---|
schemaName | string | The schema name (metadata.name). |
name | string | undefined | The schema name too. Assigning to it renames the schema. A schema that has a version file gets the next version file under the new name; a draft saves a draft. |
id / schemaFileId | string | undefined | The schema's stable ID: the file's id, one derived from the schema's name when the file has none, or a random one for a schema made with Schema.create. Used in file names and by Schema.find. |
version | number | undefined | The schema's version. |
metadata | { name, createdAt, updatedAt } | undefined | The schema's metadata block. |
$schema | string | undefined | The format URL. |
enums | object | undefined | The enums block from the file. |
migrations | array | undefined | The migrations block from the file. |
models | Model[] | The schema's Model instances. A snapshot, not reactive; use useModels in React. |
status | 'loading' | 'ready' | 'error' | The load status: 'loading' until the schema and its models are loaded, then 'ready', or 'error' if the load failed. |
isEdited | boolean | true when the schema has unsaved edits, including ones stored before this instance loaded: a ModelProperty edit or rename, or a model created with Model.create, made in an earlier session or while no Schema instance was loaded. |
validationErrors | ValidationError[] | Errors from the last validation. |
isValid | boolean | true when validationErrors is empty. |
lastSaveError | Error | undefined | The error from the last background draft save that an assignment started, such as a ConflictError. Cleared when a later one succeeds. |
waitForReady({ timeout }) resolves once status is 'ready'. It rejects with the load's error, or after timeout milliseconds (default 15000). While status is 'error', entityLoadError(schema) returns that error.
models is read-only. Assigning to it throws Cannot set schema.models directly; use Model.create(name, schema, { properties }) to add a model.
Assigning to version, enums, migrations, metadata or $schema updates the instance and saves a draft of the schema to the local database in the background. See Saving and conflicts before relying on this. On a schema that was destroyed or evicted (see unload and destroy), assigning to any of these fields or to name throws EntityUnloadedError.
Schema.all
Lists the schemas in the local database and files directory as Schema instances.
static all(options?: SchemaAllOptions): Promise<Schema[]>Parameters
| Name | Type | Default | Description |
|---|---|---|---|
options.includeAllVersions | boolean | false | Meant to return every version of each schema. Instances are cached by name, so the result still has one Schema per name. |
options.includeInternal | boolean | false | Include the SDK's internal Seed Protocol schema, which defines the Seed, Version, Metadata, Image, File and Html models. |
options.waitForReady | boolean | false | Wait for each schema to load before resolving. |
options.readyTimeout | number | 15000 | How long to wait for each schema, in milliseconds. |
options.hold | boolean | false | Take a cache hold on each schema returned, released with unload(). A call that rejects keeps none. |
Example
const schemas = await Schema.all()
console.log(schemas.map((schema) => schema.schemaName)) // ['blog']SchemaAllOptions isn't exported from the package entry, so pass the options object inline.
Schema.find
Finds a schema by its ID or its name. Unlike Schema.create, it never creates one.
static find(
options: { waitForReady?: boolean; readyTimeout?: number; hold?: boolean } & (
| { schemaFileId: string }
| { schemaName: string }
),
): Promise<Schema | undefined>waitForReady defaults to true, readyTimeout to 15000 milliseconds and hold to false; a call that resolves to undefined or rejects keeps no hold. By ID, it resolves to undefined if there's no schema with that ID, or if loading fails or times out. By name, it resolves to undefined if the local database has no schema with that name; once one is found, a failed or timed-out load rejects, as with Schema.create.
const blog = await Schema.find({ schemaName: 'blog' })
const sameSchema = await Schema.find({ schemaFileId: blog!.id! })Instance methods
validate
validate(): Promise<{ isValid: boolean; errors: any[] }>Checks the schema's structure, for example that every Relation points at a model in the schema and every List has a refValueType. A schema still loading is validated once it loads, or after 15 seconds if it hasn't.
saveNewVersion
saveNewVersion(): Promise<string>Writes the schema, with any edits to its models and properties, to a new version file in the files directory ({id}_{name}_v{n}.json, where n is one more than the highest version file on disk) and returns the file path. The new file is the previous version file with the instances' models merged in by ID, so fields the instances don't carry, such as descriptions, are kept. Those edits include ones stored before this instance loaded, such as a property renamed before a restart. Resolves to '' when there's nothing to save. Throws if validation fails, throws a ConflictError when the database changed since the instance loaded (see below), and throws EntityUnloadedError on a destroyed or evicted schema.
The file lists every model, including ones added in code, with each property in the schema file format, validation rules included. The instance's version goes up by one, and its metadata and migrations follow the saved file.
The new file only takes effect if the next start reads it. If your app passes the older file as config.schema, the next start reports the older version, and every saveNewVersion() throws ConflictError (Local version: 1, DB version: 2). After saving, copy the new file over the one you pass as config.schema, or stop passing config.schema. See Change the schema: edit, raise the version, restart.
reload
reload(): Promise<void>Reloads the schema from the local database. Rejects with Failed to reload schema from database if the load fails, and with EntityUnloadedError on a destroyed or evicted schema. status stays 'ready' while it reloads.
unload and destroy
unload(): void
destroy(): Promise<void>
static retain(schema: Schema): booleanA schema stays cached and running until it's destroyed or evicted, whether or not anything holds it. client.stop() evicts every cached schema. A hold only counts your use of the instance: Schema.create, Schema.find and Schema.all take one when called with hold: true, and Schema.retain(schema) takes one on a cached, running schema, returning false (with no hold) when the schema was stopped. unload() gives one hold back, and does nothing when there's none to give back. Releasing the last hold doesn't stop the schema, and nothing in the database changes.
Once a schema is destroyed or evicted, saveNewVersion(), reload() and assignments to its fields throw EntityUnloadedError, whose message names the schema and says to look it up again with Schema.find. EntityUnloadedError is exported from the main entry.
destroy() deletes the schema from the local database: its schema rows, its models' rows and their property definitions. It also deletes the SDK's version files for the schema (every version) from the files directory, so Schema.all() doesn't bring it back. It stops the schema and its cached models and properties, whoever holds them. It doesn't delete items or anything published. If the schema is still in your config.schema, the client adds it back on the next start. Items' stored values for the schema's properties are kept but unlinked from the deleted properties. The Promise rejects if the delete fails.
getService
getService(): ActorRefFrom<typeof schemaMachine>Returns the schema's underlying xstate actor. React hooks use it to subscribe to changes; app code rarely needs it.
Saving and conflicts
Schema edits are saved in two places:
- Drafts in the database. Assigning to a schema field, or editing a model or model property, saves a draft to the local database straight away. These saves run in the background, so errors, including conflicts, aren't thrown. When a save started by assigning a schema field or renaming a model fails, the error is logged with
console.warnand kept onschema.lastSaveError(ormodel.lastSaveError) until a later save succeeds. - Version files.
saveNewVersion()writes the draft to a new JSON file and marks it published. Renaming a schema that has a version file also writes the next version file.
Before both kinds of save, the SDK compares the schema row's updatedAt in the database with the value the instance loaded. If the database is newer, for example because another tab or process saved the schema, the save fails with a ConflictError. Only saveNewVersion() throws it to you; a background draft save records it on lastSaveError.
ConflictError is exported from the main entry. It carries a conflict object:
| Field | Type | Description |
|---|---|---|
hasConflict | boolean | Always true on a thrown error. |
localVersion | number | The version the instance loaded. |
dbVersion | number | The version in the database. |
localUpdatedAt | string | The instance's metadata.updatedAt. |
dbUpdatedAt | string | The database row's updatedAt, as ISO 8601. |
message | string | Database was updated externally. Local version: …, DB version: … |
import { ConflictError } from '@seedprotocol/sdk'
try {
await blog.saveNewVersion()
} catch (error) {
if (error instanceof ConflictError) {
await blog.reload() // pick up the newer data, then re-apply your edit
} else {
throw error
}
}The conflict check is skipped (and the save goes ahead) if the instance has no load timestamp or the database query fails. See Conflict detection for how conflicts work across the SDK.
The schema file format
config.schema takes a SchemaFileInput object (or a path to a JSON file in this format), which requires only metadata.name and models. The SDK writes the full format, SchemaFileFormat, when it saves a version with saveNewVersion(); that type requires every top-level key except id and nameMatch.
Top-level fields
The Required column is for config.schema.
| Field | Type | Required | Description |
|---|---|---|---|
$schema | string | no | The format identifier, https://seedprotocol.io/schemas/data-model/v1. A file without one is stored with this value. A file that sets another keeps it: the SDK doesn't check the value, and it isn't part of the content compared when a schema is applied. |
version | number | no | Integer version of the schema, starting at 1, the default. Appears in the file name the SDK writes (…_v{version}.json). Raise it with every edit: an edit at the same version replaces the stored file for that version, so the previous file is gone, and the SDK logs [seed] schema <name> v<n>: content changed but id <id> and version did not; replacing the stored schema file. Bump "version" when the schema changes. See Change the schema. |
id | string | no | The schema's stable ID. Without one, the SDK keeps the ID it stored for a schema of this name, or derives one from the name (the first 10 hex characters of the SHA3-256 hash of blog) and writes it into the stored version file. Model and property IDs are filled in the same way, from blog/Post and blog/Post/title. A model or property renamed in a file without IDs is a new one, so before renaming, copy the IDs from the stored version file into your file. |
metadata | object | yes | See metadata. |
models | object | yes | Model definitions keyed by model name. See models. |
enums | object | no | Shared enum definitions. Use {}. The SDK stores this block but doesn't use it for validation; to restrict a property's values, use validation.enum on the property. |
migrations | array | no | A record of schema changes. Use []. See migrations. |
nameMatch | 'exact' | 'normalized' | 'fuzzy' | no | How loosely names from EAS match this schema's models and properties, unless a model or property sets its own. Default 'fuzzy'. See nameMatch. |
metadata
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | The schema name. This is what you pass to Schema.create, useModels('blog') and the schemaName option of model and item APIs. |
createdAt | string | no | ISO 8601 timestamp. Defaults to the time the SDK first loads the file. |
updatedAt | string | no | ISO 8601 timestamp. Defaults to the time the SDK first loads the file. |
models
Each key is a model name (PascalCase by convention). Each value is:
| Field | Type | Required | Description |
|---|---|---|---|
properties | object | yes | Property definitions keyed by property name. See ModelProperty for every field and type. |
description | string | no | Notes for people and tooling. The SDK keeps it in the file but doesn't load it. |
id | string | no | The model's stable ID. Without one, the SDK keeps the stored model's ID or derives one from the schema and model names. |
indexes | string[] | no | Accepted for compatibility; the SDK ignores it. |
nameMatch | 'exact' | 'normalized' | 'fuzzy' | no | How loosely names from EAS match the model's name, and the default for its properties. See nameMatch. |
A published item is attested under an EAS schema named after its model's EAS name: bytes32 post for Post, bytes32 blog_post for BlogPost. What's published where has the naming rule. Two models whose EAS names are the same (HTMLPage and HtmlPage are both html_page) publish to one EAS schema, and EAS can't tell them apart; the SDK logs a warning when it loads such a file, and does the same for two properties of one model. The schema name itself isn't recorded on-chain.
nameMatch
When the SDK syncs items from EAS, or finds the model for a name an item was published under, it compares names that any app published with your model and property names. nameMatch sets how loosely they match:
| Value | A name from EAS matches when |
|---|---|
'exact' | It's the local name's EAS name, the one this SDK publishes: published_at for publishedAt. |
'normalized' | It's the same after lowercasing and keeping only letters and digits, so PublishedAt and published-at match too. This finds items published under older SDKs' names, such as htmlpage for HTMLPage. |
'fuzzy' | It's also the same after dropping a trailing plural s and, for a Relation, Image, File or Html property or a List of them, a trailing id or ids: posts matches Post, author_id matches author. There's no edit distance: tile never matches title. The default. |
{
"nameMatch": "normalized",
"models": {
"Post": {
"nameMatch": "exact",
"properties": {
"title": { "type": "Text" },
"summary": { "type": "Text", "nameMatch": "fuzzy" }
}
}
}
}Set it on a property, a model or the top level of the file; the first one set, in that order, wins. Here Post and title match exactly, summary fuzzily, and every other model and its properties normalized. When a model is defined in several schemas, the strictest level among them applies to its name.
The closest match wins. An attestation that matches two of a model's properties equally closely is skipped with a warning. One that matches a property only at a looser level than the property allows is dropped, not stored under a name of its own. A value other than the three logs a warning when the schema loads and is ignored, so the next level up applies.
migrations
Each entry is a record, not a script. The SDK doesn't run migrations; it appends an entry when it writes a new version file.
| Field | Type | Description |
|---|---|---|
version | number | The schema version this entry describes. |
timestamp | string | ISO 8601 timestamp. |
description | string | Free text, for example Initial schema. |
changes | any[] | A list of change records. The SDK writes { "type": "full_schema_write" } from saveNewVersion() and { "type": "schema_rename", "oldName": …, "newName": … } for a rename. |
Complete example
{
"$schema": "https://seedprotocol.io/schemas/data-model/v1",
"version": 1,
"metadata": {
"name": "blog",
"createdAt": "2026-10-14T00:00:00.000Z",
"updatedAt": "2026-10-14T00:00:00.000Z"
},
"models": {
"Post": {
"description": "A blog post",
"properties": {
"title": { "type": "Text", "required": true, "validation": { "maxLength": 120 } },
"status": { "type": "Text", "validation": { "enum": ["draft", "live"] } },
"body": { "type": "Html" },
"tags": { "type": "List", "refValueType": "Text" },
"author": { "type": "Relation", "model": "Author", "required": true }
}
},
"Author": {
"properties": {
"name": { "type": "Text", "required": true }
}
}
},
"enums": {},
"migrations": []
}The SDK also accepts a shorter { "name": "blog", "models": { … } } form at runtime, but it doesn't match the SchemaFileInput type, which needs metadata.name.
How the client loads it
When the client starts, it loads its internal Seed Protocol schema, then your config.schema, and writes the models and properties into the local SQLite database. It writes a schema only when its ID, version or content changed since it was last written, not counting $schema and the metadata timestamps, and skips it otherwise (since 0.7.9, so the first start after upgrading writes each schema once more). A schema file's rows are written in one transaction. The SDK also keeps a copy of each version in the files directory, named {id}_{name}_v{version}.json.
If the file can't be read or applied, client.init() rejects with Could not load config.schema from "<path>": <reason>, or Could not load config.schema: <reason> when you passed an object. A schema without metadata.name fails this way, with the reason Schema name is required (either at top level or in metadata.name). So does a property with an unknown type name, or with a pattern, minLength or maxLength rule on a type other than Text or Html, and nothing from the file is stored; the reason names each one, such as Schema blog: Post.wordCount: validation.maxLength only apply to Text and Html properties (this one is Number). Remove it or use enum. See ModelProperty: Validation. See client.init.
Related
- Your first schema: write a schema file step by step
- Schemas, Models, Items, Properties: how the pieces fit together
- Define a schema and add to it
- Model and ModelProperty: the classes inside a schema
- React schema hooks:
useSchema,useSchemasand friends
Client and configuration
The client singleton, client.init, the readiness helpers and every SeedConfig option, plus storage, gateway and EAS settings, environment variables and the multi-tab lock.
Model
The Model class: look up the models in your schema, create items from them, list models, define new models in code, and how model edits are saved.