Seed Protocol
@seedprotocol/sdk

ModelProperty

Every property type and option you can declare on a model, how each type is stored, published and read back, and the ModelProperty class for working with property definitions in code.

A model property is the definition of one field on a model: its name, its data type and its options. You declare properties in seed.schema.json (or in Model.create), and the SDK represents each one as a ModelProperty instance. This page covers the definition format first, then the class.

import { ModelProperty, ModelPropertyDataTypes } from '@seedprotocol/sdk'

A model property is a definition. The value stored on one item is an ItemProperty.

Property definition reference

The same options appear in two shapes. The schema file uses type and, for a relation's target, model. Code (Model.create, ModelProperty.create) uses dataType and ref.

seed.schema.json
"author": { "type": "Relation", "model": "Author", "required": true }
author: { dataType: 'Relation', ref: 'Author', required: true }
Schema fileIn codeApplies toDescription
typedataTypeallRequired. One of the ten data types. Case-insensitive; string is accepted for Text. Any other name, such as Integer or blob, fails the schema load with unknown type "Integer". Expected one of: Text, Number, …, and in code fails validate() with code unknown_data_type.
requiredrequiredalltrue marks the property as required. Not checked when you save locally; publishing checks required Relation, Image, File, Html and Json properties.
validationvalidationsee ValidationRules checked when a value is saved.
modelrefRelationThe target model's name, in the same schema.
refValueTyperefValueTypeList, RelationFor a List, the element type, for example Text or Relation. For a single Relation to Image, File or Html, that type: { "type": "Relation", "refValueType": "Image", "model": "Image" } saves an assigned data: or blob: URL as an Image. Without it, such a relation rejects a data: URL with Relation target not found. A File or Blob is rejected either way (ItemPropertySaveValidationError); declare { "type": "Image" } for uploads.
refrefList of RelationThe target model's name. With ref and no refValueType, the list is a list of relations.
storagestorageType, localStorageDir, filenameSuffixTextSee Storage.
nameMatchnoneall'exact', 'normalized' or 'fuzzy': how loosely names from EAS match this property when syncing. Defaults to the model's, then the schema's, then 'fuzzy'. See nameMatch.
descriptionnoneallNotes for people. Kept in the file, not loaded.
ididallThe property's stable ID. Without one, the SDK keeps the stored property's ID or derives one from the schema, model and property names; see id.

A List with neither refValueType nor ref fails to import, with List property "…" in model "…" requires "refValueType".

Data types

Every type except List holds one value; saving an array to any other type fails validation with code array_not_allowed. The ModelPropertyDataTypes enum has a member for each type (ModelPropertyDataTypes.Text is 'Text').

TypeWriteReads back asLocal storageEAS field type
Texta string, up to 255 characters by defaultthe stringSQLitestring
Numbera numbera string: '42'SQLitestring
Booleantrue or falsea string: 'true' or 'false'SQLitebool
Datean ISO 8601 date-time stringthe stringSQLiteuint256
Jsona JSON string (JSON.stringify(value))the stringSQLitebytes32
Htmlan HTML string of any lengththe HTML stringa file in the files directorybytes32
Imagea File or Blobsee File and image propertiesa file in the files directorybytes32
Filea File, or a string (see ItemProperty)see File and image propertiesa file in the files directorybytes32
Relationthe related item's seedLocalId or seedUid, as a string. An object such as { seedLocalId } is rejected (Expected union value)the related item's ID, as a stringSQLitebytes32
Listan array of refValueType valuesan arraySQLitebytes32[]

Values passed to create() and values you assign later are both validated. See Item and ItemProperty for reading and writing values.

  • Every value reads back as a string, except a List. Convert Number and Boolean values yourself.
  • Json objects are stored as '[object Object]'. Pass JSON.stringify(value) and parse when you read.

A Number publishes as its decimal string, so negative numbers, decimals and values above 255 publish. A stored value that isn't a finite number fails publishing with code publish_invalid_number.

What's published where

When you publish an item with @seedprotocol/publish, every property with a value gets its own EAS attestation. 0 and false are values; only null, undefined and '' are skipped. Html, Image and File content is uploaded to Arweave first, and the attestation holds a reference to it. Relation and List of Relation attestations reference the related items' Seed UIDs, so related items need to be published too. See Permanence.

Each attestation uses an EAS schema named after the field type and the property's EAS name. The SDK's easName function (also exported from the main entry) makes that name: it splits words at case changes and before the last capital of an acronym, turns spaces, hyphens and other characters into _, and lowercases the result:

PropertyEAS schema
title: Textstring title
publishedAt: Dateuint256 published_at
wordCount: Numberstring word_count
coverURL: Textstring cover_url
author: Relationbytes32 author

A single Relation, Image, File or Html property is attested as a bytes32 reference under its own name: author: Relation is bytes32 author and cover: Image is bytes32 cover. Locally, its values are stored as authorId, whether you wrote them or they were synced from EAS, and are matched to the property by its row, so they follow a rename. A List of Relation is named from the singular property name, the target model and Ids: authors with ref: 'Identity' becomes author_identity_ids.

Any app whose model has a property with the same name and type reads the same attestations, which is why property names and types shouldn't change once you publish. When syncing, the SDK matches the names on EAS against your property names as loosely as the property's nameMatch allows. By default that ignores case and separators, a plural s and, for relations, a trailing _id, so published_at and PublishedAt both reach a local publishedAt, and older author_id attestations reach author. Sync also checks types: it skips an attestation whose EAS type doesn't fit the local property, with a warning. A Number accepts any uint* or int* type and a numeric string, so values published as uint8 by older versions still sync; every other type accepts only the type it publishes. Synced Number values, and Date values attested as integers, are stored decoded, as a decimal string and an ISO 8601 string.

Since 0.7.4, a few names publish under a different EAS name, and so under different EAS schemas, than earlier versions used: an acronym followed by a word (HTMLPage was htmlpage, now html_page), a digit before a capital (ID3Tag: id3tag, now id3_tag), and names with spaces or hyphens. Plain camelCase and PascalCase names (publishedAt, coverURL, BlogPost) are unchanged. See Migration.

Validation

Rules go under validation on a property:

"title": { "type": "Text", "validation": { "maxLength": 120 } },
"status": { "type": "Text", "validation": { "enum": ["draft", "live"] } }
RuleTypeApplies toDescription
maxLengthinteger, at least 1Text, HtmlMaximum length. Text defaults to 255; set this to raise or lower it. Html has no default.
minLengthinteger, at least 0Text, HtmlMinimum length.
enumarrayanyThe allowed values. A value not in the list fails with code enum_violation.
patternstring (regular expression)Text, HtmlThe value must match.
customstringnoneNot supported, and ignored. A schema file with it loads and values save as if it weren't there; the SDK logs a console warning (Custom validators are not supported …; it is ignored.) when it first stores that file. The property's validate() stays valid and lists custom_validation_unsupported under warnings.

pattern, minLength and maxLength on any other type (on a List, any element type but Text or Html) are an error with code rule_not_applicable. A schema file with one fails to load before anything is stored, with a message such as Post.wordCount: validation.maxLength only apply to Text and Html properties (this one is Number). Remove it or use enum.

Rules combine: pattern, minLength and maxLength all apply, and enum applies together with them. On a List, the rules apply to each element, and Text elements get the 255-character default.

A value that fails validation isn't saved. Assigning it doesn't throw; awaiting the property's save() throws an ItemPropertySaveValidationError whose validationErrors hold { field, message, code, severity } entries. The codes are type (a value of the wrong type, such as text given to a Number or an object given to a Relation), max_length_exceeded, min_length, pattern, enum_violation, format (a Date value that isn't a date) and array_not_allowed. A value outside the enum gets one enum_violation; one that also misses the pattern gets both. See ItemProperty.

Item.create checks the same rules before it writes a new item, and rejects with ItemCreateValidationError.

Rules on properties created in code (Model.create, ModelProperty.create) are stored with the property in the local database, so they apply after a restart that doesn't run the same code. Other devices don't have them: saveNewVersion() writes them into the schema's version file, and rules that must apply on every device belong in seed.schema.json. Since 0.7.4, pattern is enforced on Text and enum applies together with the other rules, so values stored by older versions may break rules that now apply.

Storage

A Text property can keep its value in a file instead of the database:

"notes": {
  "type": "Text",
  "storage": { "type": "ItemStorage", "path": "notes", "extension": ".md" }
}
FieldIn codeDescription
storage.typestorageTypeItemStorage writes the value to a file named after the item, {seedUid or seedLocalId}{extension}, in the path folder of the files directory. Any other value is stored as PropertyStorage, which saves to the database as usual.
storage.pathlocalStorageDirThe folder under the files directory. Defaults to files.
storage.extensionfilenameSuffixThe file extension, including the dot.

An ItemStorage value passed to Item.create is written to its file before create resolves.

Html, Image and File are always kept in files, so they don't need storage.

Reserved property names

These names are used for fields the SDK manages on every item. Don't use them as property names:

localId, uid, seedLocalId, seedUid, schemaUid, attestationCreatedAt, attestationRaw, createdAt, updatedAt, lastVersionPublishedAt, latestVersionLocalId, latestVersionUid, lastLocalUpdateAt, modelName, refSeedType, refValueType, refResolvedValue, refResolvedDisplayValue, type, src, alt, versionLocalId, versionsCount, versionUid, _markedForDeletion, publisher, revokedAt.

Also avoid names ending in Id or Ids: the SDK stores relation and file values under those suffixes.

Reserved Solidity words

EAS schemas can't use a word Solidity's ABI reserves, so neither a model nor a property can publish under one:

constructor, error, event, fallback, function, receive, struct, calldata, memory, storage, payable, indexed, tuple, returns, constant, external, internal, private, public, pure, view, override.

The check is on the EAS name, so Returns (which publishes as returns) and a model named Event are refused too. A List of Relation is exempt, because it publishes as <name><Model>Ids.

A schema file or config.schema with such a name doesn't load, and client.init rejects:

Could not load config.schema: Schema blog: Post.view: Property "view" publishes as "view", which EAS schemas can't use (a reserved Solidity ABI word). Rename it.

For a model, the message reads Schema blog: Event: Model "Event" publishes as "event", …. ModelProperty.create with such a name rejects with Failed to write property "view": Property "view" publishes as "view", …, and Model.create rejects with Model "Event" publishes as "event", … or Property "view" publishes as "view", …, whether the name is the model's or one in properties.

Don't rename a property to one of these words either. Assigning name doesn't throw, but the rename isn't saved to the local database and validate() returns isValid: false with an error of code reserved_name. While the bad name stands, saveNewVersion() rejects with the reserved-word message if the schema has other changes, and otherwise resolves with an empty string and saves nothing.

Getting ModelProperty instances

You rarely construct a ModelProperty yourself. A loaded model's properties array holds one for each property:

import { Model, ModelProperty } from '@seedprotocol/sdk'

const Post = await Model.find({ modelName: 'Post', schemaName: 'blog' })
const title: ModelProperty | undefined = Post?.properties.find(
  (property: ModelProperty) => property.name === 'title',
)

Model.properties is typed as any[], so annotate the instances as above. In React, use useModelProperties and useModelProperty; see React model property hooks.

Instance properties

PropertyTypeDescription
idstring | undefinedThe property's stable ID: the schema file's, one derived from the names when the file has none, or a random one for a property made with ModelProperty.create.
namestring | undefinedThe property name.
dataTypeModelPropertyDataTypes | undefinedThe data type.
refstring | undefinedFor a relation or list of relations, the target model's name.
refValueTypeModelPropertyDataTypes | undefinedFor a List, the element type. For a Relation to Image, File or Html, that type.
modelNamestring | undefinedThe model the property belongs to.
modelIdnumber | undefinedThe model's database row ID.
refModelId, refModelNamenumber, stringThe target model's row ID and name, filled in as the SDK resolves them.
storageType, localStorageDir, filenameSuffixstring | undefinedThe storage settings.
pathstring | undefined{filesDir}/{localStorageDir}/{name}{filenameSuffix} when all three are set.
status'loading' | 'ready' | 'error'The load status: 'loading' until the property's row is written (when it had none) and its definition is loaded, then 'ready', or 'error'.
isEditedbooleantrue when the definition differs from what was loaded.
validationErrorsValidationError[]Errors from the last validation of the definition.
isValidbooleantrue when validationErrors is empty.

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(property) returns that error.

required and validation aren't exposed as fields.

Editing a property

Assigning to name, dataType, ref, refValueType, storageType, localStorageDir or filenameSuffix changes the definition. The SDK validates the new definition and, if it's valid and differs from what was loaded, saves it to the local database and marks the schema as edited. This happens in the background; nothing is thrown, except on a property that was destroyed or evicted, where the assignment throws EntityUnloadedError.

A name that fails validation stays on the instance only: the row keeps the old name, and the instance shows the new one with isEdited: true and the reason in validationErrors. Assigning a valid name clears the errors and saves it.

Item properties that were already loaded don't take an edit made this way: on Node they keep checking new values against the definition they loaded with. A schema change that rewrites the property's row (a new version of the schema file applied, or updateModelProperties) does reach them, so the next value assigned is checked against the new rules.

if (title) {
  title.name = 'headline'
}

Renaming a property or changing its type changes the EAS schema its values are published under, so readers stop finding existing values. Prefer adding a new property. Items' stored values follow the rename: after it, item.headline returns them and item.title is undefined. The rename is kept in the local database across restarts, but other devices and new databases don't get it until it's in a schema file: saveNewVersion() writes it to a new version file, including after a restart. If your app passes seed.schema.json as config.schema, make the same change there and set the property's id (title.id) on it, or the file's property is a new one.

ModelProperty.create

Adds a property to a loaded model, or returns the cached instance if that model already has a property with this name.

static create(
  property: {
    name?: string
    dataType: PropertyDataType // 'Text', 'Number', … or a ModelPropertyDataTypes member
    modelName?: string
    ref?: string
    refValueType?: PropertyDataType
    required?: boolean
    storageType?: 'ItemStorage' | 'PropertyStorage'
    localStorageDir?: string
    filenameSuffix?: string
    // …and the other fields of the SDK's TProperty type
  },
  options?: { waitForReady?: true; readyTimeout?: number; schemaName?: string; hold?: boolean },
): Promise<ModelProperty>

The parameter type is simplified; the real type is TypeBox's Static<typeof TProperty>, which isn't exported. Passing { waitForReady: false } returns the instance instead of a Promise; every other call returns a Promise, and is typed that way.

Parameters

NameTypeDefaultDescription
property.namestringnoneThe property name.
property.dataTypePropertyDataTyperequiredThe type, as a string ('Text') or an enum member (ModelPropertyDataTypes.Text).
property.modelNamestringnoneThe model to add the property to.
property.refstringnoneThe target model, for a relation.
options.schemaNamestringnoneThe schema the model belongs to. Pass it; model names are only unique within a schema.
options.waitForReadybooleantrueWait for the instance to be ready.
options.readyTimeoutnumber15000How long to wait, in milliseconds.
options.holdbooleanfalseTake a cache hold, released with unload(). The property stays cached without one. A call that rejects gives its hold back.

Example

import { ModelProperty } from '@seedprotocol/sdk'

const summary = await ModelProperty.create(
  { name: 'summary', dataType: 'Text', modelName: 'Post' },
  { schemaName: 'blog' },
)

When it waits for ready (the default), the Promise resolves once the property's row is written to the local database. It rejects if the write fails, for example with Cannot write property "summary": model Post has no database row when the model isn't in the database, or if the write doesn't finish within readyTimeout. With { waitForReady: false }, write failures are only logged. To define a new model's properties, pass them to Model.create.

Other methods

MethodReturnsDescription
ModelProperty.all(modelFileId, { waitForReady?, readyTimeout?, hold? })Promise<ModelProperty[]>Every property of a model, by the model's ID (model.id). waitForReady defaults to false, readyTimeout to 15000.
ModelProperty.find({ propertyFileId, waitForReady?, readyTimeout?, hold? })Promise<ModelProperty | undefined>One property by its ID. Waits by default, up to 15000 ms.
ModelProperty.getById(propertyFileId, { hold? })ModelProperty | undefinedSynchronous lookup of a loaded property.
ModelProperty.retain(property)booleanTakes one more hold on a cached, running property, released with unload(). false, with no hold taken, when the property was stopped.
validate()Promise<{ isValid, errors }>Validates the definition. Each error is { field, message, code, severity }. Resolves within 5 seconds.
save()voidSaves an edited definition to the local database. Returns at once; does nothing if the property isn't edited. Edits already save on assignment, so you rarely need it. Throws EntityUnloadedError on a destroyed or evicted property.
setSchemaName(schemaName)voidTells the instance which schema it belongs to.
unload()voidGives back one hold taken with hold: true or ModelProperty.retain; does nothing when there's none to give back. Releasing the last hold doesn't stop the property, and nothing in the database changes.
destroy()Promise<void>Deletes the property from the local database and the schema, and stops the instance, whoever holds it. Items' stored values for it are kept but unlinked from the property (property_id set to null). Rejects if the delete fails.
reload()Promise<void>Does nothing; reload the parent model or schema instead.
getService()actorThe underlying xstate actor.

hold defaults to false on each lookup. A property stays cached and running until it, its model or its schema is destroyed or evicted, whether or not anything holds it; client.stop() evicts every cached property. A hold only counts your use of the instance. Once a property is destroyed or evicted, save() and assignments to its fields throw EntityUnloadedError, whose message names the property and says to look it up again with ModelProperty.find. EntityUnloadedError is exported from the main entry.

The class also exposes bookkeeping helpers used by the SDK (createById, refreshFromDb, holdForModel, evictForModels, evictForModel, trackPendingWrite, clearPendingWrite, getPendingPropertyIds, getPendingModelId). You don't need them in app code.

Data type helpers

Also exported from @seedprotocol/sdk:

ExportDescription
ModelPropertyDataTypesEnum of the ten type names.
isDataType(value, expected)Case-insensitive type comparison: isDataType('list', 'List') is true.
normalizeDataType(value)Normalizes a type name: 'string' and 'text' become 'Text'.

On this page