Seed Protocol
Guides

Change a schema after you've shipped

Which schema changes are safe once users have local data and published items, how to rename or replace a property, and how to roll a change out without a migration system.

Once your app is in users' hands, its schema lives in two places you don't control: the local database on every device, and the EAS schemas that published items are attested under. Make additive changes, and rename or retype a property only as described below. The SDK has no migration runner: the migrations array in the schema file is a record that the client stores and never acts on (migrations). Renaming summary to excerpt in the file with a property_rename entry for it leaves excerpt empty and the values under summary.

You'll need: a schema you've already published items with, and the edit, raise-version, restart loop from Define a schema and add to it.

Where your schema lives

On each device, the client applies config.schema every time it starts. It adds new models and properties and updates changed ones, but it never deletes anything. A new app version brings its schema with it, so a device picks up the change the first time the new code runs. There's no server-side step and no migration to schedule.

On EAS, the only parts of your schema that exist are names and types. Each model and property is an EAS schema named after its EAS type and EAS name: Post is bytes32 post, title: Text is string title, and a List of Relation also includes the target model, so authors with ref: "Author" is bytes32[] author_author_ids (What's published where). Your schema's name, its version and its migrations aren't recorded on-chain. EAS schemas can't be changed or deleted, and they're shared by every app: any app whose Post has a title of type Text reads and writes the same string title attestations. The first publish of a model or property nobody has used before registers its EAS schema.

When a device syncs from EAS, the SDK matches each attested name to one of your model's properties, by default loosely: case, separators, a plural s and a relation's _id are ignored, so published_at, PublishedAt and publishedAts all reach a local publishedAt. nameMatch sets how loosely. The value is stored under your property's name, unless its EAS type doesn't fit your property's type, in which case sync skips it with a warning (How EAS sync works). That's why names and types are the contract.

Older SDKs published some models and properties differently: acronyms and digits weren't split (HTMLPage was htmlpage), a single relation could be published as author_id, and a Number was published as uint8. Sync still finds those values; nameMatch: 'exact' stops old names syncing in. Publishing uses the current name and type, so the first publish of such a model or property registers a new EAS schema, and readers that look only for the old one, such as older app versions, don't see values published under the new one.

Safe changes

These changes are safe on devices that already have data and for items that are already published:

ChangeLocallyOn EASNotes
Add a modelAdded on next startIts bytes32 schema is registered on the first publishPick a name you'll keep. Other apps may already use it
Add a propertyAdded on next start, empty on existing items, and can be assigned and saved right awayRegistered the first time a value is publishedPublished items show no value until someone sets it and publishes them again
Raise or relax a validation ruleApplies to new items and the next assignmentNot on-chainStored values aren't re-checked
Tighten a validation ruleApplies to new items and the next assignmentNot on-chainExisting values that break the rule stay, so a user's next edit to them may fail. Imports that create items from older data may need { validate: false }
Add required: trueNot checked locallyNot on-chainFor Relation, Image, File, Html and Json properties, publishing an item without the value fails until it's set
Edit a descriptionStored, not usedNot on-chain

Fill in a new property has a backfill script. Older app versions keep working after you add a property: they don't define it, so their code never asks for it.

Unsafe changes

ChangeWhat happens
Rename a propertyValues published under the old name stay there, in attestations nobody can move. Your app stops reading them, and other apps and older versions of yours still read the old name. Locally, values stay under the old name and the new one starts empty, unless the file gives the property's id (Rename a property)
Rename a modelPublished items keep their bytes32 post Seed attestation forever, so they don't belong to the renamed model. Locally, without the model's id in the file a new, empty model is added next to the old one; with it the definition is renamed, but existing items stay under the old model name either way
Change a property's typeEach type publishes under its own EAS schema (uint256 published_at for a Date, string published_at for a Text), and sync skips an attestation whose EAS type doesn't fit the local property. So devices on the new version skip values published under the old type, and devices on an older version skip new ones (SDKs before 0.7.5 don't check types and store both). Types that publish the same EAS type still see each other's values: Number and Text both publish as string, and a Number property also reads integer attestations such as old uint8 ones, skipping only values that aren't numeric. Locally, stored values aren't converted, and the next assignment is validated against the new type
Change a List of Relation's targetThe target model is part of the attested name: authors with ref: "Author" is author_author_ids, and with ref: "Identity" it's author_identity_ids. It behaves like a rename
Point a Relation at another modelA single relation's attested name doesn't include the target model, so it doesn't change. Old values still load, but they point at items of the old model
Remove a property or modelNothing is deleted locally, and everything published stays on EAS. A removed property's definition stays in each device's database: existing items keep their values, and code can still write the property, including through create(). A removed model drops out of Model.all(schemaName) from the next start after the change, but its items stay, and Item.all and Item.create by model name keep working

Rename a property

To rename a property and keep its local values, give the new name the old property's id. The id is in the version file the SDK writes to the files directory ({id}_blog_v3.json, under models.Author.properties.bio.id) and on the loaded ModelProperty as property.id. To rename Author.bio to about, replace the bio entry with:

seed.schema.json (excerpt)
"about": { "type": "Text", "id": "e50bb83531" }

Raise version and ship. Each device that starts with the new file renames the stored property and moves its values to about. Without the id, about is a new, empty property and the values stay under bio.

That moves local values only. Values already published stay on EAS under string bio, sync stores them under bio rather than about, and other apps and older versions of yours keep reading bio. Once a property has published values, replace it instead, as in the next section.

The SDK also exports renameModelProperty(schemaName, modelName, oldName, newName), which writes a new version file with the property renamed and moves its values. The rename stays on this device after a restart, even though seed.schema.json still has the old name. Other devices don't get it, so the id in the file is the way to rename everywhere.

Replace a property you got wrong

A replacement is a new property that lives next to the old one. Say you shipped summary: Text and now want excerpt: Text with a higher length limit.

1. Add the new property and keep the old one

seed.schema.json (excerpt)
"summary": { "type": "Text", "description": "Deprecated: read through readExcerpt()" },
"excerpt": { "type": "Text", "validation": { "maxLength": 1000 } }

Raise version and ship. Every device adds excerpt on its next start.

2. Read the new name, falling back to the old one

Items published or synced before the change only have summary. Read through one helper so the fallback lives in one place:

src/excerpt.ts
type PostFields = { summary?: string | null; excerpt?: string | null }

export function readExcerpt(post: PostFields): string {
  return post.excerpt || post.summary || ''
}

3. Write only the new name

Every form and import writes excerpt. When a user edits an old post, the value they save goes to excerpt, and the next time they publish it, the item gets an excerpt attestation. Readers using the fallback see the right value either way.

4. Optionally copy local values

You can copy summary into excerpt on each device, for example once at startup after the update. Only copy items the user owns: other users' items are read-only, and nobody else can publish a new value for them.

src/migrateExcerpt.ts
import { Item, isItemOwned } from '@seedprotocol/sdk'
import type { IItem } from '@seedprotocol/sdk'

type Post = IItem<any> & { summary?: string; excerpt?: string }

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

  for (const post of posts) {
    if (post.excerpt || !post.summary) continue
    if (!(await isItemOwned(post))) continue
    post.excerpt = post.summary
    await post.properties.find((p) => p.propertyName === 'excerpt')?.save()
  }
}

Copying only changes the local database. Published items keep their old attestations until the user publishes again, and each publish costs gas, so don't republish every item only to move a value. The fallback in step 2 makes that unnecessary.

Leave summary in the file for good. Removing it doesn't delete it from devices, and keeping it documents why the data has two names.

Change a type

Use the same steps with a new name, and convert the values as you copy them. To turn wordCount: Number into a Text property, add wordCountText: Text, read wordCountText ?? String(wordCount), and write only the new property. Don't change the type of an existing property.

Rolling out a change

  • Ship the schema with the code that uses it. The schema is applied when the new code starts, so a device never has code that expects a property its database doesn't have.
  • Expect several app versions at once. Web apps update on reload, but installed apps, open tabs and other teams' apps that read your models keep the old shape for a while. Additive changes are safe for all of them.
  • Don't read schema.version to decide what a device has. In the session that applies an update, a Schema instance still reports the previous version. The models and properties are current.
  • Test the upgrade path, not only a fresh install. Run the old version, create and publish items, then start the new version on the same files directory and check that old items still render through your fallbacks.

On this page