Seed Protocol
Concepts

Local-first and offline

Why the local database is your app's source of truth, what works without a network and what doesn't, how EAS sync brings published data in, and how publishing survives a reload.

A Seed app reads and writes a database on the user's device, not a server. The network only matters at the edges: pulling published data in from EAS, and pushing the user's content out when they publish.

The local database is the source of truth

When the client starts, it opens a SQLite database at <filesDir>/db/seed.db and a file store next to it. In the browser that's SQLite compiled to WebAssembly (through SQLocal) in the browser's private file system (OPFS), with files in the same OPFS tree. On Node it's a libSQL file on disk. Client and configuration has the details.

Everything your app does with data goes through that database:

  • Item.all, Item.find, useItems and live queries read SQLite. They never call the network.
  • Assigning post.title = '…' validates the value and queues it in the local outbox, from which it's written to SQLite. There's no request to wait for. A value that breaks a rule stays on screen with its errors and isn't written.
  • Drafts survive reloads, restarts and days without a connection, because they're ordinary rows on the device.
  • Published items that came in through EAS sync are rows in the same tables, so one query returns drafts and published items together.

The React layer follows the same rule. SeedProvider's React Query client uses networkMode: 'offlineFirst', so hooks run their queries even when the browser reports it's offline. Their data is local anyway.

This is the reason Seed is local-first rather than "offline-capable": there's no remote copy that the local one caches. The device's database is the app's view of the world. What's on EAS and Arweave is the public record of what the user chose to publish, and the SDK copies it into the local database when it syncs.

What works offline, and what needs the network

Works offlineNeeds the network
client.init, with the default gateway settingsSyncing published data in from EAS
Loading your schema from config.schemaPublishing: Arweave uploads and EAS attestations
Creating, editing and deleting items, Image, File and Html values includedRevoking (unpublishing) an item
Queries and live queriesDownloading files that aren't on the device yet
Ownership checks and claimUnpublishedDrafts@seedprotocol/query and @seedprotocol/feed reads
Files and images already on the devicePublish cost estimates

Init doesn't wait for the network with the default gateway settings: it looks up your models' EAS schemas in the background, and if that fails, init still succeeds and the lookup is retried later. It does wait for a proxy or Hyper sidecar you configure, and throws if it can't reach one, unless you turn that check off. See Gateway settings.

After the browser's offline event, Item.create doesn't resolve for a model whose EAS schema UID isn't stored locally yet, even after the connection returns; Creating items offline has the detail.

Files of synced items are downloaded after each sync in the browser, into OPFS. A file that hasn't been downloaded yet is fetched from an Arweave gateway when something asks for it, which fails offline. Files the user created on the device are already local.

Reading in: how EAS sync works

Published data reaches the local database through EAS sync. A sync asks the EAS indexer for the attestations made by a set of addresses, and stores the ones the database doesn't have yet.

Which addresses and which data

By default a sync covers every owned and watched address saved with client.setAddresses (or client.init({ addresses })). client.syncFromEas({ addresses }) overrides the list for one run. With no addresses, a sync does nothing.

For those addresses, a sync:

  1. Finds the EAS schemas for your models. Models with no EAS schema yet are skipped.
  2. Fetches every Seed attestation those addresses made under those schemas, including revoked ones, and inserts the Seeds it doesn't have.
  3. Fetches and stores their Version and property attestations, recording each value's attestation time. An attestation of an empty value, and a revoked property attestation on an item that isn't revoked, is stored as the property with no value. A property attestation whose EAS type doesn't fit the matching local property is skipped with a console warning, and so is one whose name matches a local property only at a looser nameMatch level than that property allows.
  4. Fetches the items those properties point to through relations, one level deep, whoever published them. An unfilled reference, which a publish left in a slot it never filled, counts as no related item: a relation holding one is skipped with a console warning, and a list drops it and keeps its other members.
  5. In the browser, schedules downloads of the synced items' files into OPFS.
  6. Emits easSeedDataSyncedToDb (EAS_SEED_DATA_SYNCED_TO_DB_EVENT). In the browser, Item instances already in memory have read the synced rows through the change feed by then; on Node, the sync reads them again first.

Syncs aren't incremental. Each run asks the indexer for all of the addresses' Seeds and skips the ones already stored, so a sync after a long time offline costs the same as any other.

When syncs run

TriggerWhenNotes
Client startAfter the background EAS schema lookup finds your models' schemasCovers the addresses passed to init. Runs whatever syncFromEasOnAddressChange is set to
Address changeclient.setAddresses saves a non-empty address listOnly when syncFromEasOnAddressChange is true, the default. Addresses passed to the first init don't count as a change, and a repeat init leaves the stored ones alone
Your codeawait client.syncFromEas()Resolves when the run finishes, throws if it failed

Requests that arrive while a sync is running are merged into one follow-up run. In the browser, the automatic triggers run only in the leader tab, while client.syncFromEas() runs in the tab that calls it.

Each run is recorded in the eas_sync_processes table, so you can show progress or a "last sync failed" message with the EAS sync hooks.

What sync doesn't do

There's no background schedule, and the SDK doesn't poll EAS. What it retries is work that failed, which usually means the device was offline: the start-up schema lookup and the syncs it starts itself are retried when the browser comes back online, or on a backing-off timer. A client.syncFromEas() call that fails isn't retried; its promise rejects, though automatic syncs merged into its run are retried. A sync that succeeded isn't repeated when the connection comes back, so if your app needs fresh data after a reconnect, sync then, from the leader tab, as Syncing across devices shows. Offline retries has the timings and how to turn retries off.

A sync interrupted by closing the tab isn't resumed. The next sync marks its eas_sync_processes row failed (Interrupted: the sync stopped before it finished (tab closed?)) and fetches everything again.

How synced values meet local edits

Every value of a property is a row, and readers show the newest row for each property, revoked or not: a synced attestation is dated by its attestation time, a local edit by when it was last made. Edits you make to a published item stay local until you publish again, and getPublishPendingDiff(item) lists them; setting a property back to its published value removes its edit. A later sync doesn't replace an unpublished edit, whenever the synced value was attested: the edit stays the value, and when the synced value differs from both the edit and the value the edit replaced, the SDK records a conflict on the property. Conflict detection covers what to do with one.

Drafts never leave the device through sync. Sync only brings in what was published, so an unpublished draft written on a laptop doesn't appear on the user's phone. Syncing across devices covers the multi-device setup.

Writing out: publishing is explicit

Local edits go through the outbox, but it only queues writes to the database on the device. Nothing in it is sent anywhere when the network returns, because there's nowhere to send it: the only outbound path is publishing, which the user starts on purpose.

Publishing, with @seedprotocol/publish, runs as a process that steps through uploading to Arweave and writing EAS attestations. It saves its progress to the publish_processes table as it goes, which is what lets a publish survive a reload:

  • Publishes cut off by a reload or a closed tab resume. When a page loads, @seedprotocol/publish restores every publish still marked in_progress and continues it from its last saved step. This happens once per page load; a tab that's already open doesn't pick up a publish another tab abandoned.
  • Except during a bundler or Turbo upload. The signed data items being uploaded aren't saved with the publish, so one cut off at that step has nothing to resume with. The restore marks it interrupted instead of continuing, and the user has to publish again. (The upload step itself refuses to run without its data items, failing with MissingSignedDataItemsError from @seedprotocol/publish, code SIGNED_DATA_ITEMS_MISSING, rather than attesting files that were never uploaded.)
  • Failed publishes stay failed. If a step before the attestations fails, for example because the network dropped during an upload, the process ends as failed and isn't retried automatically. A failure while attesting waits for a retry instead; see What a publish does.
  • Stopped publishes stay stopped. PublishManager.stopPublish marks a publish interrupted, and it isn't resumed.

In the browser, only one tab runs a given item's publish. See Multi-tab behavior.

What this means for your code

  • Treat sync and publish as the online operations. Everything else works without a connection, so they're the only parts worth showing a connection state for.
  • Trigger syncs yourself when freshness matters: on reconnect, on focus, or from a refresh button.
  • Show publish state, and offer to publish again. A failed or interrupted publish waits for the user. The publish hooks read the saved process rows.
  • Pass the user's addresses to init at each start once you know them, and call setAddresses when they change, so syncs cover the right accounts. See Addresses.
  • If the app has to open offline, don't configure a proxy or sidecar that must answer at start-up, or turn the check off.

On this page