Seed Protocol
Tutorial: build a blog

Reading and writing data

Query items, follow relations, work with lists and rich property types, and understand how the SDK saves every change locally.

Everything on this page reads and writes the SQLite database on the device. Nothing is signed or uploaded until you publish, which needs the separate @seedprotocol/publish package; see Publishing your first item.

The examples use the blog schema from Your first schema: a Post model (title, summary, body, published, publishedAt, wordCount, tags, and an author relation) and an Author model (name, bio). Some sections add an authors list and a metadata property to Post. The React components render inside the SeedProvider and SeedClientGate tree from Create the blog app, and the Seed hooks wait for the client on their own.

Typing items in TypeScript

The SDK adds one accessor per model property to each item at runtime, so the IItem type doesn't list title, author and the rest. Describe your fields once and cast where you read items. The value types below are explained in What you get back.

src/types.ts
import type { IItem } from '@seedprotocol/sdk'

export type Post = IItem<any> & {
  title?: string
  summary?: string
  body?: string
  published?: boolean | string
  publishedAt?: string
  wordCount?: number | string
  author?: string | null
  tags?: string[]
  authors?: string[] | string
  metadata?: string
}

export type Author = IItem<any> & { name?: string; bio?: string }

How reads and writes work

Each model property on an item is an accessor backed by an ItemProperty instance. Reading post.title returns that property's current value. Assigning to it is the write API, and saves automatically: there is no separate save() call for ordinary edits.

post.title = 'Revised headline'
post.published = true

The assignment checks the value against the model property and shows it right away, so your UI shows it on the next render. If it passes, the assignment also adds a write for it to the outbox, the local queue of item edits, and the SDK applies queued writes to SQLite in order. The assignment returns before the write is applied and never throws. If the item's properties haven't loaded yet, for example on an item from Item.all without waitForReady: true, the assignment only sets a plain field on the object and nothing is saved. When you need to know that a value was saved, or why it wasn't, wait on the property's save(). It resolves once the write is applied and throws if validation or the write failed:

import { ItemPropertySaveValidationError } from '@seedprotocol/sdk'

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

try {
  await titleProp?.save()
} catch (err) {
  if (err instanceof ItemPropertySaveValidationError) {
    console.error(err.validationErrors) // [{ field, message, code, severity }]
  }
}

In React, useItem and useItemProperty re-render when a property value changes, including when another component makes the change. For the design behind this, see Items, actors and the write path.

Reading properties

What you get back

The SDK stores most property values as text in SQLite (Html, Image and File content goes to separate files), so some types don't come back as the JavaScript type you assigned:

Property typeRead value
Textstring
Htmlthe HTML string
Boolean'true' / 'false' strings once saved or loaded, for example post.published === 'true'
Numbera numeric string once saved or loaded, for example '42'
Datethe ISO 8601 string you stored
Jsonthe JSON string you stored (parse it yourself)
Relationthe related item's ID (string), or undefined when unset
List of Textstring[]
List of Relationan array of IDs
Imagea blob: URL when the file is local, otherwise undefined; see Image and File
Filea blob: URL for the file's content; see Image and File

These helpers do the conversions:

src/values.ts
export const asBoolean = (v: unknown) => v === true || v === 'true'

export const asNumber = (v: unknown) =>
  v === undefined || v === null || v === '' ? undefined : Number(v)

export function asIdList(v: unknown): string[] {
  if (Array.isArray(v)) return v
  if (typeof v === 'string' && v.startsWith('[')) return JSON.parse(v)
  return []
}

Relation values are plain IDs. A local item's ID is its seedLocalId. A reference to an item that only exists on-chain is its seedUid (0x…). When you publish, a relation set on this device, and each ID in a list of relations, is rewritten to the related item's seedUid. Relations are never expanded into nested objects. To show the author's name on a post card, load the related item (covered below).

Via the properties array

Every item also has item.properties, an array of the model's ItemProperty instances, each with propertyName and value. Generic editors can iterate it without hard-coding field names:

for (const prop of post.properties) {
  console.log(prop.propertyName, prop.value)
}

In React, prefer useItemProperty when a component only needs one field.

Querying items

List the items of a model

useItems from @seedprotocol/react returns the items of a model and updates as items are created or deleted:

import { useItems } from '@seedprotocol/react'
import type { Post } from './types'

function PostList() {
  const { items, isLoading, error } = useItems({ modelName: 'Post' })
  const posts = items as Post[]

  if (isLoading) return <p>Loading…</p>
  if (error) return <p>Could not load posts: {error.message}</p>

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.seedLocalId}>{post.title || 'Untitled'}</li>
      ))}
    </ul>
  )
}

By default, useItems returns drafts only: items with no Seed attestation on EAS yet. Items you published, or synced from EAS, are left out. To list drafts and on-chain items together, pass includeEas: true:

const { items } = useItems({ modelName: 'Post', includeEas: true })

Results are sorted newest first, by the time of the last published version, then the attestation time, then createdAt, so with includeEas: true a recently published old item comes first. deleted: true lists deleted items instead (see Deleting items). The other options, addressFilter, schemaName and modelFileId, are in the React items reference.

To tell a draft from an on-chain item, for example for a badge, use getSeedPublishState. It is async and returns status: 'draft' | 'onchain':

import { getSeedPublishState } from '@seedprotocol/sdk'

const { status } = await getSeedPublishState(post)

Imperative queries with Item.all

Outside components, for scripts, loaders and event handlers, use Item.all. Its arguments are positional: (modelName, deleted, options).

import { Item } from '@seedprotocol/sdk'
import type { Post } from './types'

const posts = (await Item.all('Post', false, { waitForReady: true })) as Post[]

for (const post of posts) {
  console.log(post.title, post.seedLocalId)
}

deleted defaults to false. The options take includeEas (default false, so drafts only, as with useItems), addressFilter, schemaName, modelFileId, and waitForReady (default false). Pass waitForReady: true so property values are loaded before you read them. Item.all sorts by createdAt, newest first.

To load one item by ID, use Item.find. It returns undefined when nothing matches:

const post = (await Item.find({ modelName: 'Post', seedLocalId })) as Post | undefined

Filter in application code

Relations are stored on the "many" side: each Post points to its Author, not the other way around. There is no built-in inverse lookup. To find posts for a given author, load posts and filter:

import { Item } from '@seedprotocol/sdk'
import type { Post } from './types'

const posts = (await Item.all('Post', false, { waitForReady: true })) as Post[]

const postsByAuthor = posts.filter((post) => post.author === author.seedLocalId)

For filters that should run in SQL, use useLiveQuery: see Querying with liveQuery.

Following relations

Because post.author is an ID, load the related item when you need its fields. useItem takes the model name and either a seedLocalId or a seedUid, and returns { item, isLoading, error }. A relation can hold either kind of ID, so resolveSeedIdsFromRefString sorts it into the right field:

import { useItem } from '@seedprotocol/react'
import { resolveSeedIdsFromRefString } from '@seedprotocol/sdk'
import type { Author, Post } from './types'

function PostCard({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const post = item as Post | undefined

  const authorIds = post?.author ? resolveSeedIdsFromRefString(post.author) : {}
  const { item: authorItem } = useItem({ modelName: 'Author', ...authorIds })
  const author = authorItem as Author | undefined

  if (!post) return null

  return (
    <article>
      <h2>{post.title || 'Untitled'}</h2>
      <p>By {author?.name ?? 'Unknown author'}</p>
    </article>
  )
}

While no ID is set, useItem doesn't query and returns item: undefined.

To write a relation, assign the target item's ID:

post.author = author.seedLocalId

To clear it, assign null or call the property's clear(). When the write is applied, the SDK checks that a seedLocalId names an item in the local database: a missing one fails the write, post.author shows its previous value again, and save() rejects with an OutboxOpFailedError whose cause is a RelationTargetError listing the missing IDs. Item.create checks the relation values you create an item with the same way and rejects with the same error, leaving nothing written. A seedUid isn't checked, so take IDs from real items.

Working with lists

Only List properties hold more than one value. The blog schema's tags is a list of text. For several authors per post, add a list of relations:

"authors": { "type": "List", "refValueType": "Relation", "ref": "Author" }

List of text

Read and write a text list as an array. Always assign a new array; changing the array in place doesn't save anything.

// Read
console.log(post.tags) // ['local-first', 'seed-protocol']

// Replace the whole list
post.tags = ['local-first', 'seed-protocol', 'sqlite']

// Append: copy, add, assign
post.tags = [...(post.tags ?? []), 'draft']

Assigning a single non-empty string stores a one-element list. Each element is validated as Text, so the 255-character default limit applies to each tag.

List of relations

Assign an array of item IDs:

post.authors = [authorA.seedLocalId, authorB.seedLocalId]

You can pass the same array when creating the item:

import { Item } from '@seedprotocol/sdk'

await Item.create({
  modelName: 'Post',
  title: 'Co-authored post',
  authors: [authorA.seedLocalId, authorB.seedLocalId],
})

It reads back as an array of IDs. On list rows it can be undefined until the item finishes loading, so asIdList(post.authors) is still a handy guard.

Rich property types

Json

For a Json property such as "metadata": { "type": "Json" }, the SDK stores the value as text and doesn't convert objects for you. Assigning an object stores the string '[object Object]'. Stringify on write and parse on read:

post.metadata = JSON.stringify({ readingTime: 5, series: 'getting-started' })

const metadata = post.metadata ? JSON.parse(post.metadata) : null
console.log(metadata?.readingTime) // 5

To display parsed JSON in React, SeedJson from @seedprotocol/react renders it as formatted text. It takes the value as value and never executes it.

Html

Html properties hold HTML strings. Unlike Text, they have no default length limit, so use Html (or File) for long content such as a post body. The SDK stores and returns the raw markup and does not sanitize it. Arweave links in it, such as ar://<id> or /raw/<id>, come back re-hosted on the reader's first read gateway; the stored file keeps them as written (how readers re-host links). To render it, use SeedHtml with a sanitizer you provide, for example DOMPurify:

import DOMPurify from 'dompurify'
import { SeedHtml, useItemProperty } from '@seedprotocol/react'

function PostBody({ seedLocalId }: { seedLocalId: string }) {
  const { property } = useItemProperty({ seedLocalId, propertyName: 'body' })
  const html = property?.value != null ? String(property.value) : undefined

  return <SeedHtml html={html} sanitize={(raw) => DOMPurify.sanitize(raw)} />
}

A body that isn't on this device yet reads undefined, so SeedHtml renders nothing; useItemProperty returns valueStatus: 'missing' for it, which you can use to show a placeholder.

To show the markup as plain text (tags visible, not interpreted), render {String(post.body ?? '')} in a normal element instead.

Text, Number, Boolean and Date

  • Text: strings up to 255 characters unless the property's validation.maxLength says otherwise. Longer values are rejected.
  • Number: assign a number. Non-numbers are rejected. Reads return a numeric string, so use asNumber(post.wordCount).
  • Boolean: assign true or false. Anything else is rejected. Reads return 'true' or 'false', so use asBoolean(post.published).
  • Date: store ISO 8601 strings, for example post.publishedAt = new Date().toISOString(), or the same value in the values you create the item with. The SDK accepts any string Date.parse can read. Reads return the string you stored.

Image and File

Image and File values are stored as files on the device and linked to the item. Reading an Image property returns a blob: URL once the file is available locally. An Image, File or Html property whose file isn't on this device, such as one synced from another device and not downloaded yet, reads undefined, and its valueStatus is 'missing'. To display one, pass the property to SeedImage from @seedprotocol/react. Reading a File property returns a blob: URL for its content, not the content itself; fetch it to read the content. The SDK makes a new URL each time the item loads, so the value works after a page reload. It revokes a URL it made when the value moves to another file and when the item is unloaded, so read the property again after a change instead of keeping the URL. Uploads, downloads and display components are covered in Working with file and image properties.

Writing updates

Assign to the item, or to an entry in item.properties. Both go through the same write path:

post.title = 'Updated headline'
post.summary = 'A shorter blurb.'
post.published = true
const titleProp = post.properties.find((p) => p.propertyName === 'title')
if (titleProp) {
  titleProp.value = 'Updated via properties array'
}

A checkbox or select can assign in its change handler. A text input that assigns on every keystroke queues a write per keystroke, so keep the text in local state while the user types and assign it when they finish: Adding reactivity in React shows the pattern and the alternatives.

Validation

Each assignment checks the value against the model property:

  • the value's type (Number needs a number, Boolean a boolean, Relation an ID string, only List takes an array)
  • the 255-character default limit for Text
  • any validation rules in your schema file (minLength, maxLength, pattern, enum; see ModelProperty)

Item.create runs the same checks on the values you create an item with and also rejects keys the model doesn't have. When any check fails, including on an Html, Image or File value, it rejects and leaves nothing written; Creating items covers its errors.

Local saves and create don't enforce required, so you can keep incomplete drafts while editing. Relation targets are checked later, when the write is applied (Following relations). Assigning to a name that isn't a property of the model sets a plain JavaScript field on that object and saves nothing.

When a value fails these checks, nothing is written, and post.title keeps the rejected value until the next assignment. A fresh load, after a restart, reads the stored value. Read the reasons from the property, or await its save() as shown in How reads and writes work:

const titleProp = post.properties.find((p) => p.propertyName === 'title')
console.log(titleProp?.saveValidationErrors) // [] when the last assignment passed

Writes also check that the item belongs to the current user. On an item owned by another address, the write fails when it's applied: nothing is written, the value shows the stored one again, and save() throws an OwnershipError. A draft with no publisher, such as one created before any wallet connected, is yours, and the next wallet to connect becomes its publisher; see Ownership and drafts.

Deleting items

Delete an item with item.destroy(). In React, useDeleteItem wraps the same call and tracks loading and error state:

import type { Item } from '@seedprotocol/sdk'
import { useDeleteItem, useItem } from '@seedprotocol/react'

function DeletePostButton({ seedLocalId }: { seedLocalId: string }) {
  const { item: post } = useItem({ modelName: 'Post', seedLocalId })
  const { deleteItem, isLoading, error } = useDeleteItem()

  if (!post) return null

  return (
    <>
      <button
        disabled={isLoading}
        onClick={() => deleteItem(post as Item<any>).catch(() => {})}
      >
        {isLoading ? 'Deleting…' : 'Delete post'}
      </button>
      {error && <p>{error.message}</p>}
    </>
  )
}

deleteItem is typed to take the Item class while useItem returns the IItem interface, hence the cast. It rejects when the delete fails (for example when the item isn't yours) and also puts the error in error, so catch the promise.

Deleting is a soft delete: the SDK marks the item as deleted in SQLite instead of removing its rows. Deleted items drop out of useItems and Item.all results. To list them, for example in a trash view, pass deleted: true:

const { items: trashed } = useItems({ modelName: 'Post', deleted: true })
const trashed = await Item.all('Post', true, { waitForReady: true })

Other items that point to a deleted item keep its ID in their relations. Update those relations yourself. Deleting a local copy doesn't affect anything already attested on-chain.

A complete example: filter, relate, and edit

This component lists an author's posts and lets you toggle each post's published flag. Each row loads its post with useItem, because useItems re-renders when posts are created or deleted, not when a field changes:

import { useMemo } from 'react'
import { useItem, useItems } from '@seedprotocol/react'
import type { Author, Post } from './types'
import { asBoolean } from './values'

function AuthorPosts({ authorId }: { authorId: string }) {
  const { item: authorItem } = useItem({ modelName: 'Author', seedLocalId: authorId })
  const author = authorItem as Author | undefined
  const { items } = useItems({ modelName: 'Post' })

  const postIds = useMemo(
    () =>
      (items as Post[])
        .filter((post) => post.author === authorId)
        .map((post) => post.seedLocalId),
    [items, authorId],
  )

  return (
    <section>
      <h2>Posts by {author?.name ?? '…'}</h2>
      <ul>
        {postIds.map((id) => (
          <PostRow key={id} seedLocalId={id} />
        ))}
      </ul>
    </section>
  )
}

function PostRow({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const post = item as Post | undefined
  if (!post) return null

  const isPublished = asBoolean(post.published)

  return (
    <li>
      <span>{post.title || 'Untitled'}</span>
      <button onClick={() => { post.published = !isPublished }}>
        {isPublished ? 'Mark as draft' : 'Mark as published'}
      </button>
    </li>
  )
}

published here is an ordinary Boolean field in the blog schema. Toggling it only changes a value in SQLite. Putting the post on-chain is a separate step: see Publishing your first item.

Where to go next

On this page