Seed Protocol
@seedprotocol/sdk

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 }): Schema

Parameters

NameTypeDefaultDescription
schemaNamestringrequiredThe schema's metadata.name, for example 'blog'. Throws Schema name is required if empty.
options.waitForReadybooleantrueWait until the schema and its models have loaded before resolving.
options.readyTimeoutnumber15000How long to wait, in milliseconds. The Promise rejects if the schema doesn't load in time.
options.holdbooleanfalseTake 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

PropertyTypeDescription
schemaNamestringThe schema name (metadata.name).
namestring | undefinedThe 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 / schemaFileIdstring | undefinedThe 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.
versionnumber | undefinedThe schema's version.
metadata{ name, createdAt, updatedAt } | undefinedThe schema's metadata block.
$schemastring | undefinedThe format URL.
enumsobject | undefinedThe enums block from the file.
migrationsarray | undefinedThe migrations block from the file.
modelsModel[]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.
isEditedbooleantrue 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.
validationErrorsValidationError[]Errors from the last validation.
isValidbooleantrue when validationErrors is empty.
lastSaveErrorError | undefinedThe 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

NameTypeDefaultDescription
options.includeAllVersionsbooleanfalseMeant to return every version of each schema. Instances are cached by name, so the result still has one Schema per name.
options.includeInternalbooleanfalseInclude the SDK's internal Seed Protocol schema, which defines the Seed, Version, Metadata, Image, File and Html models.
options.waitForReadybooleanfalseWait for each schema to load before resolving.
options.readyTimeoutnumber15000How long to wait for each schema, in milliseconds.
options.holdbooleanfalseTake 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): boolean

A 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.warn and kept on schema.lastSaveError (or model.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:

FieldTypeDescription
hasConflictbooleanAlways true on a thrown error.
localVersionnumberThe version the instance loaded.
dbVersionnumberThe version in the database.
localUpdatedAtstringThe instance's metadata.updatedAt.
dbUpdatedAtstringThe database row's updatedAt, as ISO 8601.
messagestringDatabase 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.

FieldTypeRequiredDescription
$schemastringnoThe 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.
versionnumbernoInteger 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.
idstringnoThe 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.
metadataobjectyesSee metadata.
modelsobjectyesModel definitions keyed by model name. See models.
enumsobjectnoShared 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.
migrationsarraynoA record of schema changes. Use []. See migrations.
nameMatch'exact' | 'normalized' | 'fuzzy'noHow loosely names from EAS match this schema's models and properties, unless a model or property sets its own. Default 'fuzzy'. See nameMatch.

metadata

FieldTypeRequiredDescription
namestringyesThe schema name. This is what you pass to Schema.create, useModels('blog') and the schemaName option of model and item APIs.
createdAtstringnoISO 8601 timestamp. Defaults to the time the SDK first loads the file.
updatedAtstringnoISO 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:

FieldTypeRequiredDescription
propertiesobjectyesProperty definitions keyed by property name. See ModelProperty for every field and type.
descriptionstringnoNotes for people and tooling. The SDK keeps it in the file but doesn't load it.
idstringnoThe model's stable ID. Without one, the SDK keeps the stored model's ID or derives one from the schema and model names.
indexesstring[]noAccepted for compatibility; the SDK ignores it.
nameMatch'exact' | 'normalized' | 'fuzzy'noHow 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:

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

FieldTypeDescription
versionnumberThe schema version this entry describes.
timestampstringISO 8601 timestamp.
descriptionstringFree text, for example Initial schema.
changesany[]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

seed.schema.json
{
  "$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.

On this page