Seed Protocol
Concepts

Items, actors and the write path

Why Seed objects behave like live views rather than plain data, and the path every write takes from assignment to an in-memory actor, through the outbox to SQLite and back.

Every Schema, Model, ModelProperty, Item and ItemProperty you get from the SDK is a proxy in front of an XState actor, shared by everyone who loads the same ID. You write by assigning to a field: the actor shows the value, the assignment queues a write in the outbox, the SDK applies it to SQLite, and in the browser stored changes flow back into the actor, which is what your components read. Your code never writes to the database or reads item values from it directly.

The actor holds the state

When the SDK creates an Item, it also creates an actor for it: a small state machine whose context holds the item's IDs, model name and property instances. Each ItemProperty has its own actor, whose context holds the value, the row IDs and the validation errors from the last assignment. Schema, Model and ModelProperty work the same way.

Each object's status is its load status:

A property stays 'ready' while its writes are applied; writing doesn't change status. waitForReady() resolves once it's 'ready'.

Reads return the actor's current snapshot; they don't query SQLite. Writes send the actor an event and queue a write.

You rarely deal with actors yourself. item.getService() and property.getService() return them, and subscribe() on either object listens to them. The React hooks use both; Reactivity model covers how.

The object you hold is a proxy

A JavaScript Proxy intercepts reads and writes of an object's fields. The SDK wraps each instance in one, so fields that look like plain properties are views onto the actor:

  • Identity fields on an item (seedLocalId, seedUid, modelName, schemaUid, latestVersionLocalId, latestVersionUid, publisher, revokedAt) are read from the item actor's context on every access.
  • Model fields (post.title, post.author) are accessors the item defines for each property of its model. Each one finds the matching ItemProperty and reads or writes its value.
  • value on an ItemProperty reads the actor's current value. Assigning it updates the value in memory and queues a write.

Schema, Model and ModelProperty follow the same pattern. Assigning schema.name or model.modelName updates the actor and saves a draft of the change to the database.

The loop

Your component doesn't talk to SQLite, and SQLite doesn't talk to your component: both go through the actor.

Loading: the database fills the actor

When you load an item with Item.find, Item.all or a hook, the SDK creates an actor for it and for each of its properties. Each property reads its current row, and its status turns 'ready'. Until then its value is undefined. That's what waitForReady waits for, and why fields on useItems rows can be undefined for a moment.

Writing: the assignment queues a write

Assignment is the only write path for property values. When you assign post.title = 'Revised headline':

  1. The actor updates at once. The value in memory is the new one, and useItem and useItemProperty re-render with it. This is an optimistic update: it happens before the value is written.
  2. The assignment checks the value and queues it. It checks the value against the model property (type, length, the schema's validation rules) and, if it passes, adds a write for it to the outbox before the assignment returns. The SDK then applies the write: it checks that you own the item, and writes the value to SQLite for the item's current version.
  3. The outcome lands on the actor. Once the write is applied, the actor holds the stored value; after a save or a reload most types read back as strings ('42' where you assigned 42). A value that breaks a rule isn't queued: it stays shown, with the reasons in saveValidationErrors, until the next assignment, and a fresh load reads the stored value. A write that's refused or fails, such as on an item you don't own or for a File whose data can't be read, writes nothing, and the property shows its current value again: the newest write still queued for it from any tab, else the stored value.

Each assignment queues its own write, in assignment order, so an input bound to property.value queues one write per keystroke and keeps every keystroke. Assigning three fields queues three writes with no transaction around them: if one breaks a rule, the other two are still saved.

The outbox

The outbox is a table in the local database that holds item edits waiting to be written. Nothing in it leaves the device; publishing is a separate, explicit step.

  • In the browser the leader tab applies the outbox, whichever tab made the edit. On Node the process applies its own.
  • A property's writes apply in the order they were queued. When several are waiting, only the newest is written and the older ones are dropped, so typing never leaves an older value in SQLite.
  • A write that sets a published property back to its published value deletes the property's drafts instead of storing a new one, so the next publish leaves it out. ItemProperty gives the exceptions.
  • Each write is applied in one transaction. Item.create queues the item, its first version and its values as one write, so a failure leaves nothing.
  • A File or Image value given as a Blob or a data: URL has its bytes copied to a staging file by the tab that assigned it; applying the write stores them in the value's own file.
  • A write is queued before the assignment returns, so a value assigned just before a reload or a closed tab shows after it, and the write is applied once a tab leads again.

Publishing waits first for the queued writes on the items it publishes: the item, the drafts it relates to and the files its values point to. Writes to other items don't hold it up. Raw SQL on metadata sees a value only once its write is applied, so await property.save() before reading it yourself.

Returning: stored changes flow back

In the browser, each loaded ItemProperty reads its rows again when the change feed reports a write to them. The read includes writes still waiting in the outbox, from any tab, so it shows the newest queued value where there is one rather than an older stored one. That's how changes made outside your component reach it: your own write once it's applied, an edit in another tab, or an EAS sync that wrote newer published values.

On Node there's no change feed. Loaded items are read again after each EAS sync, and nothing else reaches them, including writes another process makes to the same database: load the item again to see those.

Writes don't check whether the stored value changed since you loaded it, so when two tabs edit the same field the later write wins, with no merge and no error. See Conflict detection.

Assignment can't report failure

JavaScript ignores what a setter returns, and an assignment expression always evaluates to the value you assigned. So the assignment itself has no way to tell you that a write failed. The proxy accepts the value, the write is applied in the background, and its outcome lands on the property's actor:

import type { IItem } from '@seedprotocol/sdk'

type Post = IItem<any> & { title?: string }

async function rename(post: Post, title: string) {
  post.title = title // returns normally, even if the value is invalid

  const titleProp = post.properties.find((p) => p.propertyName === 'title')
  await titleProp?.save() // waits for that write, throws if it failed
}

save() doesn't start a second write. It waits for the one the assignment queued, with no time limit unless you pass { timeoutMs }, and throws ItemPropertySaveValidationError when the value broke a rule, an OwnershipError on an item you don't own, or an error named OutboxOpFailedError (code: 'OUTBOX_OP_FAILED') when applying the write failed. With timeoutMs it rejects with OutboxSaveTimeoutError once that time passes, and the write stays queued. Without save(), rule errors are still on the property as saveValidationErrors. See ItemProperty.

Two assignments on an item do throw at once: item.publisher and item.properties are read-only.

On an item you don't own, post.title = 'x' doesn't throw: the field shows the new value until its write is applied and refused, then shows the stored value again, and nothing is written. A save() on the property rejects with OwnershipError. Check isItemOwned(item) before letting the user edit; see Ownership and drafts.

One instance per ID

The SDK keeps a cache of loaded instances, keyed by seedLocalId and seedUid. Item.create, Item.find, Item.all, useItem and useItems all return the cached instance while there is one, so two components that load the same post hold the same object and subscribe to the same actors. ItemProperty instances are cached the same way, per item and property name.

That sharing is what makes edits show up everywhere: an assignment in an editor changes the actor that the sidebar row is also subscribed to. There's nothing to synchronise, because there's only one copy in memory.

Holds decide how long an instance stays cached. Item.create takes one unless you pass hold: false; Item.find, Item.all, Item.getById and ItemProperty.find take one only when called with hold: true; Item.retain(item) adds one to an instance you already have. unload() releases one, and does nothing on an instance no one holds. An Item or ItemProperty that no one holds stays cached for a grace period (entityCache.unloadGraceMs in SeedConfig, 60 seconds by default) after its last release or its last lookup without a hold, then stops. A Schema, Model or ModelProperty stays cached until it's destroyed or evicted or the client stops; its unload() only releases the caller's hold.

An item holds each of its properties, so when the item stops, its properties stop too, unless you also hold one with ItemProperty.find({ hold: true }) and haven't released that hold. The React hooks hold what they show while mounted and release it on unmount, so an item a mounted useItem shows stays loaded; useCreateItem holds the item it creates only with hold: true. A stopped instance still reads its last values, but a write to it throws EntityUnloadedError: look the item up again, and hold it while you use it. See Item and useItem.

Objects are live, not data

Because the fields are views onto an actor, an item doesn't survive being copied:

  • { ...post } copies the model fields as their current values, plus some methods. It doesn't copy seedLocalId or the other identity fields, because those aren't stored on the object. Assigning to the copy changes the copy and saves nothing.
  • JSON.stringify(post) has the same gaps. Build the object you need from the fields you want.
  • Object.keys(post) lists the model fields but not the identity fields.

The React hooks treat items accordingly: useItem turns off React Query's structural sharing for items, so it never replaces your item with a rebuilt copy.

Writes that skip the loop

A few writes don't go through a property actor:

  • Item.create checks its initial values first, then queues the item, its first version and its values as one write in the outbox: an unknown key or a value that breaks its property's rules throws ItemCreateValidationError, and nothing is written. A relation value naming a local item that doesn't exist rejects the create with OutboxOpFailedError when the write is applied. File, Image and Html values, and properties stored as ItemStorage files, go through each property's normal write once the item is written; if one of those fails, create removes the draft before it rejects. { validate: false } skips the checks, for importing data that's already stored or attested. See Item.
  • EAS sync writes published data it fetched. In the browser, loaded items pick it up through the change feed; on Node, the sync reads them again. The hooks refetch on easSeedDataSyncedToDb. A synced value doesn't replace an unpublished local edit; see Conflict detection.
  • Your own SQL through BaseDb.getAppDb(). It skips the outbox, validation and ownership checks. In the browser, loaded objects pick it up through the change feed; on Node, they don't.

What this means for your code

Do

  • Pass IDs between components and functions, and load the item where you need it with useItem or Item.find. You get the same shared instance.

  • Write by assigning to the item. Let the hooks re-render; don't push values into components yourself.

  • Show validation errors from the property. A component using useItemProperty re-renders when the errors change, so it can show saveValidationErrors:

    import { useItemProperty } from '@seedprotocol/react'
    
    function TitleField({ seedLocalId }: { seedLocalId: string }) {
      const { property } = useItemProperty({ seedLocalId, propertyName: 'title' })
      if (!property) return null
    
      const [firstError] = property.saveValidationErrors
    
      return (
        <label>
          Title
          <input
            value={String(property.value ?? '')}
            onChange={(e) => { property.value = e.target.value }}
          />
          {firstError && <span role="alert">{firstError.message}</span>}
        </label>
      )
    }

    useItem re-renders when a value changes and when the item's uids or publish state change. A value that breaks a rule stays as assigned, so useItem doesn't re-render when its errors arrive: read them through useItemProperty. A refused or failed write changes the value back, so useItem does re-render then.

  • Await save() on each property you changed before a step that depends on the stored data, such as publishing.

  • Use useDebouncedItemProperty for text inputs, so each pause in typing, not each keystroke, queues a write.

  • Check isItemOwned before showing edit controls.

Don't

  • Don't wrap an assignment in try/catch to catch validation errors. Nothing is thrown there.
  • Don't spread, clone or serialize items to pass them around, or keep a value in React state. The copy is missing fields, doesn't save, and doesn't follow the return path.
  • Don't mutate a List value in place (post.tags.push('x')). The proxy only sees assignments, so assign a new array.
  • Don't treat several assignments as one atomic change. Validate the whole form yourself first if all fields must save together.
  • Don't write item data with SQL. It skips the outbox, validation and ownership, and on Node loaded items don't see it.

On this page