Seed Protocol
@seedprotocol/query

Adapters

Where @seedprotocol/query reads from: the remote EAS source, the SDK's local SQLite source and auto mode, the exact fallback rules, how sources are registered, and how to write your own.

Every read in @seedprotocol/query goes through a data source: an object that lists Seeds and fetches their Versions and property attestations. There are two slots, remote (EAS, always available) and local (empty until something registers a source), and the source option on each call picks between them.

import { registerLocalQuerySource, type QueryDataSource } from '@seedprotocol/query'

Choosing a source

Pass source to queryBySchema, getSeed, getSeeds or queryBySchemaForMonth:

sourceReads fromCacheUse it when
'remote' (default)The EAS indexer, plus Arweave gateways for contentYesYou don't run the SDK, or you need everything that's on-chain
'local'The registered local source. With the SDK, the published items its database has synced. Never draftsNoYour app runs the SDK and you want to read without network requests
'auto'The local source when one is registered, falling back to remote as described belowOnly after falling backYou want local reads with a network fallback
import { queryBySchema, getSeed } from '@seedprotocol/query'

const local = await queryBySchema('post', { source: 'local' })
const post = await getSeed('0xfd8c50ca131b725c288be7f46e4abe0c7b06f1226e98f552642edde8280ad900', { source: 'auto' })

Fallback rules

'local' never falls back. With no local source registered it throws:

[query] source: "local" requires registerLocalQuerySource(...) (e.g. SDK DB ready)

'auto':

  • No local source registered: behaves exactly like 'remote', cache and etag included.
  • queryBySchema and queryBySchemaForMonth: reads the local source. If that returns no items at all, the whole call is repeated against EAS with the same limit, skip, uidPrefix and attesters. One local item is enough to stay local, so a partly synced collection isn't topped up from EAS.
  • getSeed: reads the local source. If it doesn't have the Seed, or has it with no Version from its attester, reads EAS (using the item cache). If it has the Seed with a Version, EAS isn't asked: a Seed whose Versions were all revoked returns null.
  • getSeeds: reads every Seed from the local source, then reads the ones it doesn't have, or has with no Version, from EAS in one batch (using the item cache). The others are never read from EAS, as with getSeed.
  • Results read locally have no etag.

Paging with 'auto' can switch sources mid-list. If the local source has 30 items and you ask for skip: 40, the local page is empty, so you get EAS's items 41 to 60, which may not continue the local list. Use one source explicitly when you page.

The local source from the SDK

The SDK registers its SQLite database as the local source when client.init finishes, and clears it on client.stop() and client.unload(). From then on source: 'local' and 'auto' read the published items its EAS sync brought in: those of the client's owned and watched addresses. To read another publisher's items locally, add their address as a watched address and let a sync run; see Sync a user's data to another device.

@seedprotocol/sdk also exports helpers around it:

ExportDescription
getPublishedSeedRecord(seedUid, options?)getSeed with source: 'local' by default. Pass { source: 'auto' } to fall back to EAS. Registers the source first if it isn't yet
registerSeedQueryLocalSource(options?: { force?: boolean })Registers a fresh SDK source. Returns false without registering while the database isn't ready, unless force
unregisterSeedQueryLocalSource()Clears the local slot
isSeedQueryLocalSourceRegistered()Whether the SDK registered its source
createLocalQueryDataSource()The SDK's QueryDataSource, if you want to wrap it

The SDK's source takes revocation from the database's revoked_at columns, which unpublishing sets before EAS's indexer catches up, so a local read can leave out an item that a remote read still returns. It also leaves out Seeds marked for deletion and Seeds whose Versions were all revoked. See Read published data for a full example, and Querying for reading the local database directly.

registerLocalQuerySource

Puts a source in the local slot, replacing whatever was there.

function registerLocalQuerySource(source: QueryDataSource): void
function clearLocalQuerySource(): void
function getRegisteredLocalQuerySource(): QueryDataSource | null
function hasLocalQuerySource(): boolean

The source must have kind: 'local'; anything else throws [query] registerLocalQuerySource expects a source with kind: "local". There's one slot per process, and the remote slot can't be replaced. Reads with source: 'local' don't check for a second copy of @seedprotocol/eas or @seedprotocol/arweave; 'auto' reads do, and throw if there is one (see Troubleshooting). To read from a different indexer, set EAS_ENDPOINT instead (see environment variables).

client.init registers the SDK's source with force, replacing any source you registered before it. In an app that runs the SDK, register your own source after client.init resolves, and remember that getPublishedSeedRecord will then read from yours.

QueryDataSource

The interface a source implements. Every method returns attestations in the shape EAS's GraphQL API uses.

type QueryDataSource = {
  readonly kind: 'remote' | 'local'

  getSeedByUid(seedUid: string): Promise<AttestationLike | null>
  listSeedsBySchemaName(schemaName: string, opts: { limit: number; skip: number } & SeedListFilter): Promise<AttestationLike[]>
  listSeedsBySchemaNameForMonth(schemaName: string, year: number, month: number): Promise<AttestationLike[]>
  getVersionsForSeed(seedUid: string): Promise<AttestationLike[]>
  getVersionsForSeeds(seedUids: string[], opts?: { includeRevoked?: boolean }): Promise<AttestationLike[]>
  getPropertiesForVersionUids(versionUids: string[]): Promise<AttestationLike[]>
  getSeedsByUids(uids: string[]): Promise<AttestationLike[]>

  listChangesSince?(opts: { refUIDs: string[]; ids: string[]; since: number }): Promise<AttestationChange[]>
  readStorageBody?(ref: { propertyName: string; value: unknown; localPathHint?: string }): Promise<string | null>
}

type SeedListFilter = {
  uidPrefix?: string             // '0x' plus lowercase hex
  attesters?: readonly string[]  // lowercase addresses, at least one
}

type AttestationLike = {
  id: string
  decodedDataJson: string
  refUID: string
  schemaId: string
  timeCreated: number
  attester?: string
  schema?: { schemaNames?: Array<{ name: string }> }
  revoked?: boolean
}
MethodUsed byContract
getSeedByUidgetSeedThe Seed attestation, with schema.schemaNames[0].name set to its schema name (getSeed returns null without it). null if unknown. Called with the UID lowercased
listSeedsBySchemaNamequeryBySchemaSeeds of the schema, newest first, revoked ones left out. When opts has uidPrefix, only UIDs starting with it; when it has attesters, only Seeds attested by one of them. Both are already normalized, and limit and skip apply after them. A source that ignores them lists Seeds the call meant to leave out
listSeedsBySchemaNameForMonthqueryBySchemaForMonthSeeds created in that month, in UTC like the built-in sources: from 00:00 UTC on the 1st, up to but not including 00:00 UTC on the next month's 1st
getVersionsForSeed, getVersionsForSeedsgetSeed, getSeeds, assembly and changelogsVersion attestations whose refUID is the Seed. With includeRevoked, include revoked ones marked revoked: true, so a Seed whose Versions were all revoked can be told apart from one that never had any
getPropertiesForVersionUidsAssembly and changelogsProperty attestations whose refUID is one of the Versions
getSeedsByUidsgetSeeds, and reading related items (not called for them with relations: 'uids')The Seeds with these UIDs, with schema.schemaNames set as for getSeedByUid. Revoked ones left out
listChangesSince (optional)The query cacheAttestations created or revoked after since that reference refUIDs or are ids. Only consulted for remote sources
readStorageBody (optional)Arweave hydrationReturn the body of an Html or File value from local storage, or null to fetch it from a gateway. Called with the field's key as propertyName and its current value, usually the gateway URL; query doesn't set localPathHint. A throw or an empty string counts as null

A source doesn't have to filter Versions and properties by attester: the query layer keeps only Versions attested by their Seed's attester, and properties attested by their Version's attester, so return each attestation's attester when you have it. Attestations without one are kept.

createRemoteQueryDataSource() returns a new instance of the built-in EAS source and getRemoteQueryDataSource() the shared one. Both are useful to wrap, as below.

Example: one publisher's items

To list one account's Seeds in one call, pass attesters to queryBySchema. To keep every listing to one account whatever the call passes, register a source that adds the account to the EAS request and reuses the remote source for everything else. It reads uidPrefix and attesters from opts, so those options still filter:

one-publisher.ts
import { EasClient, GET_SEEDS_LEAN, withExcludeRevokedFilter } from '@seedprotocol/eas'
import {
  createRemoteQueryDataSource,
  registerLocalQuerySource,
  queryBySchema,
  type AttestationLike,
  type QueryDataSource,
} from '@seedprotocol/query'

const publisher = '0xE870B14fCfF4A408e6eCb65Bd40f64d08859Efb3' // as EAS stores it: checksummed
const remote = createRemoteQueryDataSource()

const onePublisher: QueryDataSource = {
  ...remote,
  kind: 'local',
  async listSeedsBySchemaName(schemaName, { limit, skip, uidPrefix, attesters }) {
    if (attesters && !attesters.includes(publisher.toLowerCase())) return []
    const where = withExcludeRevokedFilter({
      attester: { equals: publisher },
      ...(uidPrefix ? { id: { startsWith: uidPrefix } } : {}),
      schema: { is: { schemaNames: { some: { name: { equals: schemaName } } } } },
    })
    const { itemSeeds } = await EasClient.getEasClient().request(GET_SEEDS_LEAN, { where, take: limit, skip })
    return itemSeeds.map(
      (seed): AttestationLike => ({ ...seed, schema: { schemaNames: [{ name: schemaName }] } }),
    )
  },
}

registerLocalQuerySource(onePublisher)

const { items } = await queryBySchema('post', { source: 'local', limit: 20 })

The query layer registers the EAS client before it calls the source, so EasClient.getEasClient() is ready inside the method. Reads through this source aren't cached, because it's in the local slot. Month listings (queryBySchemaForMonth) still return every publisher's Seeds unless you override listSeedsBySchemaNameForMonth too.

resolveQuerySource

Returns the source a call would use, without reading anything.

function resolveQuerySource(mode?: QuerySourceMode): {
  mode: QuerySourceMode
  dataSource: QueryDataSource
  useQueryCache: boolean
}
function normalizeSourceMode(source?: QuerySourceMode): QuerySourceMode // undefined → 'remote'

mode defaults to 'remote'. For 'local' without a registered source it throws, as above. For 'auto' it returns the local source when one is registered, otherwise the remote one; the per-call fallback described above happens later.

Browser and Node

The data sources are the same in both builds. The differences are around them:

Node and BunBrowser bundle
Remote sourceEAS through fetchThe same
Setup on the first readRegisters fetch-based EAS clients and the Node Arweave client, where none are registeredRegisters fetch-based EAS clients where none are registered, and sets the Arweave read gateway without loading the Node clients
Query cacheMemory and disk under CACHE_DIRMemory only
CACHE_* and NODE_ENVRead from process.envUsually not available, so the cache is on and records are frozen
Local sourceThe SDK's libsql database, after client.initThe SDK's SQLite WASM database in OPFS, after client.init

In a browser app that runs the SDK, prefer the local source (or getPublishedSeedRecord from @seedprotocol/sdk): it reads what the app has already synced and needs no network. Use 'remote' for items no one in the app watches.

On this page