Seed Protocol
@seedprotocol/sdk

ItemProperty

The ItemProperty class: one property value of an item, how assigning to it writes, how save() reports errors, clearing a value, conflicts after sync, and how file, image, relation and list values behave.

An ItemProperty holds one property value of one Item, such as a post's title. The accessors on an item (post.title) read and write these instances. Use an ItemProperty directly when you need to wait for a save, read validation errors, clear a value, settle a conflict or build a generic editor.

import { ItemProperty, ItemPropertySaveValidationError } from '@seedprotocol/sdk'

Get an instance from its item, or load it by ID:

import { ItemProperty } from '@seedprotocol/sdk'

const titleProp = post.properties.find((p) => p.propertyName === 'title')

const sameProp = await ItemProperty.find({ propertyName: 'title', seedLocalId: post.seedLocalId })

value

The property's current value. Reading it returns the value in memory. Assigning to it is the write API: it shows the value and writes it.

value: any

post.title and titleProp.value read and write the same thing.

Reading

The SDK stores values as text in SQLite, so after a save or a reload most types read back as strings:

Property typeRead value
Textstring
Htmlthe HTML, as a string
Boolean'true' or 'false'
Numbera numeric string, such as '42'
Datethe ISO 8601 string you stored
Jsonthe string you stored
Relationthe related item's seedLocalId or seedUid
Listan array. A list of relations can also come back as the same array in a JSON string
Imagea blob: URL in the browser, the file name in Node
Filea URL to the stored file: blob: in the browser, file:// in Node. See File, Image and Html properties

A property that has never been set reads as undefined. So does an Image, File or Html property whose file isn't on this device, such as one synced from EAS and not downloaded yet; its valueStatus is 'missing'. What you get back has helper functions that convert these values.

Writing

post.title = 'Revised headline'
// same as
titleProp.value = 'Revised headline'

Each assignment checks the value against the model property, shows it at once, so post.title and the UI show it immediately, and adds a write for it to the outbox, the local queue of item edits, before the assignment returns. The SDK applies queued writes to SQLite in order (in the browser, the leader tab does), and save() resolves once the write is applied. Because the write is queued first, a value assigned just before a page reload or a restart shows after it. Nothing in the outbox leaves the device. Unidirectional data flow explains the design.

The assignment throws only on a stopped instance, with EntityUnloadedError (see destroy and unload). To find out whether the value was written, await save().

  • Assigning the value the property already holds does nothing.
  • Assigning a published property its published value again, after an edit, removes the edit: when the write is applied, the property's drafts are deleted and the published value is current again, so the next publish, its cost estimate and getPublishPendingDiff leave it out. A Relation compares by the item it names, so its seedLocalId and its seedUid count as the same value; a List compares element by element, in order. The comparison is with the newest attested value, so going back to an older one after a sync stored a newer one is still an edit. It doesn't apply to clear() or an empty value, to a revoked value, or while a publish of the item is running. For Image, File and Html each assignment stores a new file, so assigning the same content again is still an edit.
  • Assigning a non-empty string to a List property stores a one-element list.
  • Assigning an object to a Json property stores '[object Object]'. Call JSON.stringify first.
  • A value that breaks one of the property's rules isn't written. It stays shown, through post.title, titleProp.value and every lookup that returns the cached instance, with the reasons in saveValidationErrors, until the next assignment. A fresh load of the item, after a restart or in another process, reads the stored value.
  • On an item you don't own, the write fails when it's applied: nothing is written, the property shows its current value again (the newest write still queued for it from any tab, else the stored value), and save() rejects with an OwnershipError. See Ownership.
  • Assigning null or '' to an Image, File or Html property clears it, as clear() does.

setValue

setValue(value: any): void

Shows and writes value as assigning it does, except that it writes even when the property already holds value, where an assignment does nothing. await save() for the result.

const titleProp = post.properties.find((p) => p.propertyName === 'title')

titleProp?.setValue(typed)
await titleProp?.save()

showValue

showValue(value: any): void

Shows value without writing it, for an input that writes later, such as a debounced text field. The next setValue or assignment writes. If the property re-reads its stored row before then (in the browser, when the change feed reports a write to it), the stored value shows again.

save

Waits for the property's latest write and reports how it went.

save(options?: { timeoutMs?: number }): Promise<void>

save() doesn't start a write. It waits for the write of the last assignment (or clear()) to be applied, with no time limit unless you pass timeoutMs, then:

  • resolves when the value was written, or when there was nothing to write
  • rejects with an ItemPropertySaveValidationError when the value broke a rule, so nothing was written
  • rejects with an OwnershipError (Item is read-only: you do not own this item) when you don't own the item
  • rejects with EntityUnloadedError when the property has stopped (see destroy and unload), without waiting
  • rejects with OutboxOpFailedError when applying the write failed. Its code is 'OUTBOX_OP_FAILED', its message is the original error's, and errorName is the original error's class: 'TypeError' for a URL that can't be fetched (fetch failed on Node, Failed to fetch in Chromium), 'Error' for No file data found, 'RelationTargetError' for a relation to a missing item. The property shows its current value again.
  • rejects with OutboxSaveTimeoutError when the write hasn't been applied within timeoutMs milliseconds. Its code is 'OUTBOX_SAVE_TIMEOUT' and opId is the queued write's ID. Nothing is cancelled: the write stays queued and is applied once a tab drains the outbox, so treat it as "still saving", not as a failure.

OutboxOpFailedError and OutboxSaveTimeoutError are exported from both entries, so instanceof works. When the write failed with a RelationTargetError (its targets lists the missing IDs) or a StagedValueMissingError (a write whose file bytes were never stored, because the tab that assigned them closed first; its path is the missing file), cause is an instance of that class, rebuilt from the stored failure, so it works when the write failed in another tab or before a reload. For other failures the original class survives only as errorName. A write held up by a busy database or a locked file is retried before it fails, so save() can take a while.

Without timeoutMs, a save still waiting after 10 seconds logs [seed] Saving Post.title has waited 10 s: its write is still queued. …, unless process.env.NODE_ENV is 'production'. A browser build logs it only when the bundler replaces process.env.NODE_ENV with a value other than 'production'; with no process and no replacement it stays quiet. A wait that long usually means no tab is draining the outbox, such as a frozen leader tab. The timers don't keep a Node process running.

Before waiting, it checks that you own the item, and rejects with the OwnershipError if you don't. When the save succeeds, it emits itemProperty.saved. Only save() emits it, not assignment, so a listener doesn't hear about edits nobody called save() for.

Example

import { ItemPropertySaveValidationError } from '@seedprotocol/sdk'

post.title = titleInput
const titleProp = post.properties.find((p) => p.propertyName === 'title')

try {
  await titleProp?.save()
} catch (err) {
  if (err instanceof ItemPropertySaveValidationError) {
    showErrors(err.validationErrors.map((e) => e.message))
  } else if ((err as { code?: string }).code === 'OUTBOX_OP_FAILED') {
    showErrors([`Not saved: ${(err as Error).message}`])
  } else {
    throw err
  }
}

To stop waiting after a while, pass timeoutMs:

import { OutboxSaveTimeoutError } from '@seedprotocol/sdk'

const titleProp = post.properties.find((p) => p.propertyName === 'title')

try {
  await titleProp?.save({ timeoutMs: 30_000 })
} catch (err) {
  if (err instanceof OutboxSaveTimeoutError) showErrors(['Still saving. Your edit is kept.'])
  else throw err
}

ItemPropertySaveValidationError

The error save() throws when the value failed validation.

class ItemPropertySaveValidationError extends Error {
  name: 'ItemPropertySaveValidationError'
  readonly validationErrors: ValidationError[]
}

type ValidationError = {
  field: string
  message: string
  code: string
  severity: 'error' | 'warning'
}

Its message is Invalid value for property <name>: followed by the messages, joined with ; . For example:

{
  field: 'value',
  message: 'Text must be 20 characters or less.',
  code: 'max_length_exceeded',
  severity: 'error',
}

Each assignment is checked against:

  • the value's type (code type): Number needs a number, Boolean a boolean, and only List accepts an array (array_not_allowed)
  • a 255-character limit on Text, unless the property sets validation.maxLength or enum (code max_length_exceeded, message Text properties have a default limit of 255 characters. …). Html has no default limit
  • the property's validation rules: pattern (code pattern), minLength (min_length), maxLength (max_length_exceeded) and enum (enum_violation). pattern, minLength and maxLength apply to Text and Html values only, so they don't limit a File; a schema that sets them on another type doesn't load (see Validation). A value that misses an enum and breaks the pattern too gets an error for each. For a List, each element is checked, with errors on value[0], value[1] and so on

required isn't checked when saving locally. Relation targets are checked when the write is applied, not here; see Relation and List values.

saveValidationErrors

readonly saveValidationErrors: ValidationError[]

The errors from the last assignment that broke a rule, or []. The next assignment clears them. Read it instead of await save() when you render errors next to a field.

clear

Removes the property's value.

clear(options?: { retract?: boolean }): Promise<void>

The property shows no value (null) at once, and the promise resolves when the cleared value is stored, as save() does. A clear that's refused or fails rejects the same way and shows the current value again. On a stopped property, clear() rejects with EntityUnloadedError.

What the next publish does with a cleared value depends on retract:

  • Without it, the publish attests the property's empty value: '' for Text and Number, a zero bytes32 for Image, File, Html, Json and Relation, [] for a List. Reads of the item's earlier versions keep the old value.
  • With retract: true, the publish revokes the property's attestations instead, so reads of earlier versions stop showing the value too. Revocation is permanent.

A property that was never published has nothing on chain to remove, so the publish leaves it out. Publishing and revoking covers what other apps read after each.

Boolean and Date have no empty value. clear() on one rejects with ClearNotSupportedError (exported from both entries) and the message published can't be cleared: Boolean properties have no empty value. Use clear({ retract: true }).; clear({ retract: true }) works. Assigning null or '' to an Image, File or Html property is the same as clear(). Another device that syncs the removal stores the property with no value.

import { ClearNotSupportedError } from '@seedprotocol/sdk'

const summaryProp = post.properties.find((p) => p.propertyName === 'summary')
await summaryProp?.clear()

const publishedProp = post.properties.find((p) => p.propertyName === 'published')
try {
  await publishedProp?.clear()
} catch (err) {
  if (err instanceof ClearNotSupportedError) await publishedProp?.clear({ retract: true })
  else throw err
}

Conflicts

When an EAS sync brings in a value for a property you've edited but not published, your edit stays the property's value. If the synced value differs from both your edit and the value your edit replaced, the SDK records a conflict on the property. Conflict detection explains the rule, and What wins when both devices edit shows how to handle it.

readonly conflict: PropertyConflict | null
resolveConflict(choice: 'local' | 'remote'): Promise<void>

conflict is null when there's none. A PropertyConflict (a type-only export) has these fields, with values in their stored string form (a List as JSON, a Relation as the related item's ID):

FieldTypeDescription
seedLocalIdstringThe item
propertyNamestringThe property
baseValuestring | nullThe stored value your edit was made against. null when the property had none
remoteValuestring | nullThe synced value
localValuestring | nullYour edit, which is the property's value
remoteUidstringThe attestation UID of the synced value
opIdstringThe outbox write that recorded the conflict

resolveConflict('local') keeps your edit and sets conflict to null. resolveConflict('remote') deletes the property's unpublished local values, so the synced value shows again and the next publish has nothing to send for this property. The next assignment also clears the conflict. item.conflicts() lists an item's conflicts, and the itemProperty.conflict event carries the PropertyConflict in the tab that made the edit or ran the sync.

In the browser, a conflict that a sync records over a loaded property shows on conflict once the change feed reports the sync's write. On Node, loaded items re-read their properties after each sync. A property whose edit is still waiting in the outbox shows its conflict once that write is applied. An Image, File or Html edit records none, whether it's saved or still waiting.

const titleProp = post.properties.find((p) => p.propertyName === 'title')

if (titleProp?.conflict) {
  const { localValue, remoteValue } = titleProp.conflict
  const keepMine = window.confirm(`Keep "${localValue}" instead of "${remoteValue}"?`)
  await titleProp.resolveConflict(keepMine ? 'local' : 'remote')
}

ItemProperty.find

Loads one property of an item, from the cache or from SQLite.

static find(props: {
  propertyName: string
  seedLocalId?: string
  seedUid?: string
  modelName?: string
  waitForReady?: boolean
  readyTimeout?: number
  hold?: boolean
}): Promise<ItemProperty<any> | undefined>
NameTypeDefaultDescription
propertyNamestringnoneThe property name from the schema
seedLocalId, seedUidstringnoneThe item's ID. One is required
modelNamestringnoneThe item's model, used when the stored row doesn't record it
waitForReadybooleantrueWait until the property has loaded
readyTimeoutnumber15000Milliseconds to wait
holdbooleanfalseTake a cache hold, released with unload(). Without one, a property its item doesn't hold unloads after the grace period

Returns undefined when propertyName or both IDs are missing, or when the property can't be loaded. A property that has never been set still returns an instance, whose value is undefined.

ItemProperty.all

Loads every stored property of an item.

static all(
  params: { seedLocalId?: string; seedUid?: string },
  options?: { waitForReady?: boolean; readyTimeout?: number; hold?: boolean },
): Promise<ItemProperty<any>[]>

waitForReady defaults to false here, readyTimeout to 15000 and hold to false; with hold: true each property comes with a hold you release with its unload(). It reads the item's stored values, so properties that have never been set aren't included. To reach every property of a loaded item, use item.properties.

Identity fields

FieldTypeDescription
propertyNamestringThe name in your schema, such as 'author'
storagePropertyNamestringThe name new rows of the property are stored under: authorId for a single Relation named author, authorAuthorIds for a List of Relation named authors with ref: 'Author', and the property name for other types. Older rows can hold author, so to find a property's rows, match on propertyId (Querying)
modelNamestringThe item's model
seedLocalId, seedUidstringThe item's IDs. seedUid is '' until the item is published
localIdstringLocal ID of the row that holds the current value
uidstringEAS UID of that value's attestation, or ''
versionLocalIdstring | undefinedThe item version the value belongs to
schemaUidstring | undefinedThe property's EAS schema UID, once known
publishedbooleantrue when the current value has an attestation UID
propertyDefobject | undefinedThe property definition from the schema (dataType, ref, validation and so on)
status'loading' | 'ready' | 'error'The load status: 'ready' once the property's row and definition have loaded, 'error' when that failed; entityLoadError returns the error. A property with a write pending is 'ready'

waitForReady({ timeout }) resolves once status is 'ready'. It rejects with the load's error, or after timeout milliseconds (default 15000) with Entity loading timeout after <timeout>ms.

File, Image and Html properties

These types store their content in a file and keep a reference to it in SQLite. Assigning queues the same kind of write, which stores the file and links it to the item.

TypeAcceptsStored under
Imagea File or Blob; a data: URL of any image type, SVG included (decoded); an http(s): URL on any host, localhost included, or a blob: URL (fetched); the name of a file already in the images folder (linked); a host and path without a scheme, such as cdn.example.com/a.png (fetched over https); or raw base64 of an image (decoded). Any other string fails with No file data found<filesDir>/images/
Filea File, or a string: a data: URL (decoded to bytes), an http(s): or blob: URL (fetched), the name of a file already in the files folder (linked), or any other text, saved as the file's content<filesDir>/files/
Htmlan HTML string<filesDir>/html/

Each write stores a file. A File object keeps its name with the new file record's local ID in front (LOoB5jP5k9-photo.png), so two items that each upload photo.png don't overwrite each other. So does an http(s): URL fetched into a File property, named after the URL's last path segment. Content with no source name, such as a data: URL or a Blob, gets the local ID and its MIME type's extension (QbSnxNxmO6.svg); text saved as a File gets the local ID alone. Images are also resized to several widths (480 to 1920 px) where the platform supports it. Saving a file emits file-saved with the file's path.

A write that can't fetch or read its content fails when it's applied: save() rejects with the OutboxOpFailedError described under save, and the property shows its current value again.

These fields locate the stored file:

FieldTypeDescription
refResolvedValuestring | undefinedThe stored file name, such as 'k3J9x0aB1c-cover.png'
localStorageDirstring | voidThe folder, such as '/images'
localStoragePathstring | voidThe full path to the file in the app's file system
const coverProp = post.properties.find((p) => p.propertyName === 'cover')

if (coverProp) {
  coverProp.value = fileInput.files![0]
  await coverProp.save()
  console.log(coverProp.value) // blob: URL in the browser
}

Html values read back as the HTML itself. The SDK doesn't sanitize it, but it does re-host Arweave links for display: ar://<id>, /raw/<id> on any host and /<id> on a known Arweave gateway come back on the first gateway this client reads from (see Gateway settings), keeping each link's path form, with ar:// becoming /raw/. The stored file keeps the links as written (Links in published content explains the rules). The value read is for display only, since that gateway can be a local sidecar or proxy. A Blob that isn't a File is rejected for File properties, with an ItemPropertySaveValidationError.

On a File property, a string that isn't a data: URL, a URL or an existing file name is stored as text, raw base64 included: pass a data: URL when you mean bytes.

A File value is a URL, not the file's contents, whether you assigned a File object or a string. Read the contents through it: fetch in the browser, fs with fileURLToPath on Node. The SDK makes a new URL from the stored file each time the item loads, so the value works after a page reload and, on Node, after filesDir moves.

In the browser, the SDK revokes a blob: URL it made for an Image or File value when the value moves to another file and when the property stops (see destroy and unload). A property no one holds stops after the grace period, so hold the item or property while a blob: URL from it is on screen; in React, useItem, useItemProperty and SeedImage hold what they show while mounted. Read property.value again after a change instead of keeping the URL. A blob: URL you assign is fetched into a new file and stays yours to revoke.

valueStatus and missingFile

FieldTypeDescription
valueStatus'resolved' | 'fetching' | 'missing'Whether value holds the file's content. 'fetching' while the property loads, which downloads a file that isn't on this device; 'missing' when the file wasn't found; 'resolved' otherwise. Always 'resolved' for types other than Image, File and Html
missingFile{ path?: string } | nullWhile valueStatus is 'missing', the path the file was looked for at. null otherwise

While the file is missing, value is undefined, but refResolvedValue and localStoragePath still name the file. When a save of a file with that name is announced (a download finishes, or another tab saves it), the property reads it again. An Html property that already shows a body it fetched through its Arweave transaction stays 'resolved'.

const coverProp = post.properties.find((p) => p.propertyName === 'cover')

if (coverProp?.valueStatus === 'missing') {
  console.log('Cover not on this device yet:', coverProp.missingFile?.path)
}

To display images and files in React, use the components in Images and media. Working with file and image properties covers uploads, downloads and storage.

Relation and List values

A Relation property holds one ID: the related item's seedLocalId for a local item, or its seedUid (0x…) for an item that only exists on-chain. When you publish the item, the value stored on this device is rewritten to the related item's seedUid, as an EAS sync stores it; a List of Relation becomes a JSON array of seedUids. Assign an ID, or call clear() to remove it. Related items are never loaded for you.

post.author = author.seedLocalId
await post.properties.find((p) => p.propertyName === 'author')?.save()

When the write is applied, each seedLocalId a Relation or List of Relation names must be an item in the local database. A missing one fails the write: save() rejects with errorName: 'RelationTargetError' and the message Relation target not found: <ids>. A target whose own create is still queued is waited for. seedUids aren't checked. Item.create checks its initial values the same way.

A List property holds an array. Each element is validated as the list's refValueType. Assign a new array to change it: changing the array in place saves nothing.

post.tags = [...(post.tags ?? []), 'sqlite']
post.authors = [authorA.seedLocalId, authorB.seedLocalId]

Lists are stored as JSON text and read back as arrays, including a List of Relation. See Working with lists and Modeling relations.

subscribe

subscribe(observer: { next?: (value: any) => void }): Subscription

Calls observer.next with the current value whenever the property's state changes, and returns an XState Subscription. In React, use useItemProperty from @seedprotocol/react instead. See React item properties.

destroy and unload

destroy(): Promise<void>
unload(): void

unload() releases one hold on the instance without changing data. ItemProperty.create() takes one, ItemProperty.find() and ItemProperty.all() take one only with hold: true, and an item holds each of its properties. A property you reached only through item.properties stops when its item stops. Any other property no one holds stays cached for the grace period, entityCache.unloadGraceMs in SeedConfig (60 seconds by default), after its last release or lookup, then stops; one you loaded with find({ hold: true }) keeps running until you unload() it. See Item: unload for how the grace period works.

A stopped property still reads its last value. Assigning value, setValue(), save() and clear() on it throw EntityUnloadedError, with a message that names the property and its item: Cannot set Post.title of item <seedLocalId>: this ItemProperty instance was unloaded. …. Look the item or property up again and hold it while you use it.

destroy() deletes the property's stored values. It checks ownership first and rejects with an OwnershipError on an item you don't own, and it rejects if the delete fails.

destroy() permanently deletes this item's stored rows for the property, in every version, not only the latest value. Other items' values aren't touched. The property then reads as undefined. To remove only the current value, use clear(). To delete a whole item, use item.destroy(), which is a soft delete.

ItemProperty.create

ItemProperty.create(props, options?) builds an instance from raw property data. Item calls it for you. To create an item with values, use Item.create.

On this page