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: anypost.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 type | Read value |
|---|---|
Text | string |
Html | the HTML, as a string |
Boolean | 'true' or 'false' |
Number | a numeric string, such as '42' |
Date | the ISO 8601 string you stored |
Json | the string you stored |
Relation | the related item's seedLocalId or seedUid |
List | an array. A list of relations can also come back as the same array in a JSON string |
Image | a blob: URL in the browser, the file name in Node |
File | a 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
getPublishPendingDiffleave it out. ARelationcompares by the item it names, so itsseedLocalIdand itsseedUidcount as the same value; aListcompares 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 toclear()or an empty value, to a revoked value, or while a publish of the item is running. ForImage,FileandHtmleach assignment stores a new file, so assigning the same content again is still an edit. - Assigning a non-empty string to a
Listproperty stores a one-element list. - Assigning an object to a
Jsonproperty stores'[object Object]'. CallJSON.stringifyfirst. - A value that breaks one of the property's rules isn't written. It stays shown, through
post.title,titleProp.valueand every lookup that returns the cached instance, with the reasons insaveValidationErrors, 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 anOwnershipError. See Ownership. - Assigning
nullor''to anImage,FileorHtmlproperty clears it, asclear()does.
setValue
setValue(value: any): voidShows 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): voidShows 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
ItemPropertySaveValidationErrorwhen 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
EntityUnloadedErrorwhen the property has stopped (see destroy and unload), without waiting - rejects with
OutboxOpFailedErrorwhen applying the write failed. Itscodeis'OUTBOX_OP_FAILED', itsmessageis the original error's, anderrorNameis the original error's class:'TypeError'for a URL that can't be fetched (fetch failedon Node,Failed to fetchin Chromium),'Error'forNo file data found,'RelationTargetError'for a relation to a missing item. The property shows its current value again. - rejects with
OutboxSaveTimeoutErrorwhen the write hasn't been applied withintimeoutMsmilliseconds. Itscodeis'OUTBOX_SAVE_TIMEOUT'andopIdis 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):Numberneeds a number,Booleana boolean, and onlyListaccepts an array (array_not_allowed) - a 255-character limit on
Text, unless the property setsvalidation.maxLengthorenum(codemax_length_exceeded, messageText properties have a default limit of 255 characters. …).Htmlhas no default limit - the property's
validationrules:pattern(codepattern),minLength(min_length),maxLength(max_length_exceeded) andenum(enum_violation).pattern,minLengthandmaxLengthapply toTextandHtmlvalues only, so they don't limit aFile; a schema that sets them on another type doesn't load (see Validation). A value that misses anenumand breaks thepatterntoo gets an error for each. For aList, each element is checked, with errors onvalue[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:
''forTextandNumber, a zerobytes32forImage,File,Html,JsonandRelation,[]for aList. 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):
| Field | Type | Description |
|---|---|---|
seedLocalId | string | The item |
propertyName | string | The property |
baseValue | string | null | The stored value your edit was made against. null when the property had none |
remoteValue | string | null | The synced value |
localValue | string | null | Your edit, which is the property's value |
remoteUid | string | The attestation UID of the synced value |
opId | string | The 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>| Name | Type | Default | Description |
|---|---|---|---|
propertyName | string | none | The property name from the schema |
seedLocalId, seedUid | string | none | The item's ID. One is required |
modelName | string | none | The item's model, used when the stored row doesn't record it |
waitForReady | boolean | true | Wait until the property has loaded |
readyTimeout | number | 15000 | Milliseconds to wait |
hold | boolean | false | Take 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
| Field | Type | Description |
|---|---|---|
propertyName | string | The name in your schema, such as 'author' |
storagePropertyName | string | The 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) |
modelName | string | The item's model |
seedLocalId, seedUid | string | The item's IDs. seedUid is '' until the item is published |
localId | string | Local ID of the row that holds the current value |
uid | string | EAS UID of that value's attestation, or '' |
versionLocalId | string | undefined | The item version the value belongs to |
schemaUid | string | undefined | The property's EAS schema UID, once known |
published | boolean | true when the current value has an attestation UID |
propertyDef | object | undefined | The 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.
| Type | Accepts | Stored under |
|---|---|---|
Image | a 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/ |
File | a 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/ |
Html | an 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:
| Field | Type | Description |
|---|---|---|
refResolvedValue | string | undefined | The stored file name, such as 'k3J9x0aB1c-cover.png' |
localStorageDir | string | void | The folder, such as '/images' |
localStoragePath | string | void | The 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
| Field | Type | Description |
|---|---|---|
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 } | null | While 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 }): SubscriptionCalls 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(): voidunload() 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.
Related
- Reading and writing data: how reads and writes work, with examples
- Item: the item that owns these properties
- ModelProperty: property definitions and validation rules in the schema
- React item properties:
useItemPropertyanduseDebouncedItemProperty - Working with file and image properties
- Conflict detection
Item
The Item class: create, find and list items, read their identity fields, delete them, prepare them for publishing, export published ones as archive bags and check who owns them.
Querying
How to read the local database: Item.all and find, Drizzle queries and transactions through BaseDb, live queries, the browser change feed, and helpers for published data.