Model relations between items
Define Relation and List-of-Relation properties, set and read them, load related items, find an item's children, and know what publishing does with the items you point at.
A relation lets one item point at another: a post at its author, a post at its co-authors.
You'll need: a project set up as in Add Seed to your app, the blog schema from Your first schema, and src/types.ts and src/values.ts from Reading and writing data.
Define the relations
There are two kinds:
- A
Relationholds one reference. Name the target model inmodel. - A
ListwithrefValueType: "Relation"holds several. Name the target model inref.
The blog schema already has Post.author. Add a list for co-authors to Post, and raise version as for any edit to the file (Evolving the model says why):
"author": { "type": "Relation", "model": "Author", "required": true },
"authors": { "type": "List", "refValueType": "Relation", "ref": "Author" }The target model must be in the same schema. required: true on a relation isn't checked when you save locally, only when you publish.
Image, File and Html properties are relations too: the value is a reference to an item of the SDK's internal Image, File or Html model, which holds the content. You set them with a file or a string, not an ID. See File and image properties.
A schema file can also declare an image as a Relation to Image with refValueType: "photo": { "type": "Relation", "refValueType": "Image", "model": "Image" }. Assign it a data: or blob: URL and the SDK saves the image as it does for an Image property, and SeedImage shows it. It doesn't take a File or Blob: save() rejects with ItemPropertySaveValidationError, so declare { "type": "Image" } for uploads. Without refValueType, a Relation to Image takes only an item ID, and a data: URL fails with Relation target not found.
Put the relation on the "many" side
A post has one author, and an author has many posts. Store the relation on the post, the "many" side, and don't add a posts list to Author:
- It scales. An author with a thousand posts would need a thousand IDs in one property, rewritten on every new post.
- There's one copy of the truth. With
Post.authorandAuthor.postsboth stored, they can disagree, and nothing in the SDK keeps them in sync. - The SDK has no inverse relations. You can't declare a computed
Author.posts. You find an author's posts by filtering posts, as shown below.
Use a List of Relation for a genuine many-to-many relation, such as co-authors, where each post has a short list.
Set a relation
Pass the related item's ID string: its seedLocalId, or its seedUid (0x…) if it only exists on-chain. The examples on this page cast items to the Post and Author types from Typing items.
Create the target first, then pass its ID:
import { Model } from '@seedprotocol/sdk'
import type { Post } from './types'
const Author = await Model.find({ modelName: 'Author', schemaName: 'blog' })
const Post = await Model.find({ modelName: 'Post', schemaName: 'blog' })
if (!Author || !Post) throw new Error('blog schema is not loaded')
const jane = await Author.create({ name: 'Jane Doe' })
const sam = await Author.create({ name: 'Sam Lee' })
const post = (await Post.create({
title: 'Hello, world!',
author: jane.seedLocalId,
authors: [jane.seedLocalId, sam.seedLocalId],
})) as PostTo change a relation later, assign a new ID and, if you want to know it was written, await the property's save(). Assign null to clear a Relation, and a new array to change a list:
post.author = sam.seedLocalId
await post.properties.find((p) => p.propertyName === 'author')?.save()
post.authors = [sam.seedLocalId]
await post.properties.find((p) => p.propertyName === 'authors')?.save()When an assignment or a create() is applied, each seedLocalId it names must be an item in the local database. A missing one fails the write: save() or create() rejects with an OutboxOpFailedError whose errorName is 'RelationTargetError' and whose cause lists the missing IDs in targets (save() lists the errors), and a create writes nothing. The SDK doesn't check seedUids or the target's model, so a mistyped seedUid or an item of the wrong model is stored as given. Take IDs from real items.
Use ID strings, not objects. { seedLocalId } passed to create() is rejected with ItemCreateValidationError, and assigning it to an existing item fails validation with Expected union value.
Read a relation
A Relation reads back as the related item's ID string, or undefined when it's empty. The related item isn't loaded for you.
A List of Relation reads back as an array of IDs. On list rows it can be undefined until the item has loaded, so the examples below read it through asIdList from src/values.ts.
Load the related item
A relation can hold either kind of ID. resolveSeedIdsFromRefString sorts it into { seedLocalId } or { seedUid }, which you spread into Item.find:
import { Item, resolveSeedIdsFromRefString } from '@seedprotocol/sdk'
import type { Author, Post } from './types'
import { asIdList } from './values'
export async function loadAuthor(post: Post): Promise<Author | undefined> {
if (!post.author) return undefined
return (await Item.find({
modelName: 'Author',
...resolveSeedIdsFromRefString(post.author),
})) as Author | undefined
}
export async function loadAuthors(post: Post): Promise<Author[]> {
const authors = await Promise.all(
asIdList(post.authors).map((id) =>
Item.find({ modelName: 'Author', ...resolveSeedIdsFromRefString(id) }),
),
)
return authors.filter((author): author is Author => author !== undefined)
}Item.find resolves to undefined when the item isn't in the local database, for example a seedUid from another account that hasn't been synced.
In React, useItem does the same and doesn't query while no ID is set; the tutorial's PostCard in Following relations loads a post's author that way. For a list of relations, render one component per ID so each one calls useItem:
import { useItem } from '@seedprotocol/react'
import { resolveSeedIdsFromRefString } from '@seedprotocol/sdk'
import type { Author, Post } from './types'
import { asIdList } from './values'
function AuthorName({ id }: { id: string }) {
const { item } = useItem({ modelName: 'Author', ...resolveSeedIdsFromRefString(id) })
return <li>{(item as Author | undefined)?.name ?? '…'}</li>
}
export function PostAuthors({ post }: { post: Post }) {
return (
<ul>
{asIdList(post.authors).map((id) => (
<AuthorName key={id} id={id} />
))}
</ul>
)
}Find the items that point at an item
There's no inverse lookup, so load the "many" side and filter on the relation:
import { Item } from '@seedprotocol/sdk'
import type { Post } from './types'
import { asIdList } from './values'
// Posts whose `author` is this author
export async function postsBy(authorId: string): Promise<Post[]> {
const posts = (await Item.all('Post', false, { waitForReady: true })) as Post[]
return posts.filter((post) => post.author === authorId)
}
// Posts that list this author among `authors`
export async function postsCoAuthoredBy(authorId: string): Promise<Post[]> {
const posts = (await Item.all('Post', false, { waitForReady: true })) as Post[]
return posts.filter((post) => asIdList(post.authors).includes(authorId))
}Compare against the ID form the relation actually holds. Before the post is published, a relation set on this device holds the author's seedLocalId. Publishing the post rewrites the stored value to the author's seedUid, the value its attestation holds and an EAS sync stores, and authors becomes a JSON array of seedUids the same way. So once any posts are published, or when you include synced items (includeEas: true), match either:
const ids = [author.seedLocalId, author.seedUid].filter(Boolean)
const theirPosts = posts.filter((post) => post.author && ids.includes(post.author))In React, useItems gives you the list to filter:
import { useItems } from '@seedprotocol/react'
import type { Post } from './types'
export function AuthorPosts({ authorId }: { authorId: string }) {
const { items, isLoading } = useItems({ modelName: 'Post' })
if (isLoading) return <p>Loading…</p>
const posts = (items as Post[]).filter((post) => post.author === authorId)
return (
<ul>
{posts.map((post) => (
<li key={post.seedLocalId}>{post.title || 'Untitled'}</li>
))}
</ul>
)
}useItems re-renders when posts are created or deleted, not when a post's author changes, so a post moved to another author stays in this list until the next re-render. To filter in SQL and react to field changes, use a live query: see Querying with liveQuery. In SQL, rows are stored under the storage name rather than the property name: new rows store author as authorId and authors with ref: "Author" as authorAuthorIds, and rows written by an older SDK can hold author. Filter on the property's propertyId, or on every name its rows can have (Tables). In code, post.author and post.authors read all of these.
Deleting related items
Deleting an item doesn't touch the items that point at it: posts keep the deleted author's ID in author. If you delete an author, clear or reassign their posts first, under either form of the author's ID:
for (const id of [author.seedLocalId, author.seedUid]) {
if (!id) continue
for (const post of await postsBy(id)) {
post.author = null
await post.properties.find((p) => p.propertyName === 'author')?.save()
}
}
await author.destroy()When a cleared post was already published, its next publish attests author as a zero bytes32, so other apps read it as having no author.
What publishing does with related items
When you publish a post with @seedprotocol/publish, each relation is attested as the related item's seedUid, under an EAS schema named after the property. A single relation uses the property's own name: author becomes bytes32 author, and sync still reads older author_id attestations. A list relation's name also includes the target model: authors with ref: "Author" becomes bytes32[] author_author_ids, so changing ref later behaves like renaming the property (Unsafe changes).
Related drafts are published along with the post, and related items that are already published are only referenced. A related item that was unpublished stops the publish. Publish related items together covers each case, including drafts that point at each other.
Related items are published under your account and are separate items on EAS. Unpublishing the post later doesn't revoke them. See Permanence and Publishing and revoking.
Related
- Reading and writing data: following relations, step by step
- Schemas, Models, Items, Properties: why relations live on the "many" side
- ModelProperty and ItemProperty: reference
- React item hooks:
useItemanduseItems - Guides: File and image properties, Querying with liveQuery, Publishing and revoking
Define a schema and add to it
Write a seed.schema.json whose names, types and rules hold up when you publish, then add models and properties by editing the file, raising its version and restarting.
Working with file and image properties
Add Image and File properties to a model, save uploads from a file input, show them with SeedImage, offer files for download, and know what publishing does with them.