Querying
How to read the local database: Item.all and find, Drizzle queries and transactions through BaseDb, live queries, the browser change feed, and helpers for published data.
The SDK has no query builder of its own. You read local data in one of three ways: list and load items with Item.all and Item.find, run Drizzle queries against the SQLite database through BaseDb, or subscribe to a query with BaseDb.liveQuery. For published data in its canonical JSON form, use @seedprotocol/query.
import { BaseDb, seeds, metadata } from '@seedprotocol/sdk'Choosing an approach
| You want | Use |
|---|---|
The items of a model, as Item instances you can edit | Item.all(modelName, deleted, options) |
| One item by ID | Item.find({ seedLocalId }) |
| Filtering, joins or counts in SQL | BaseDb.getAppDb() with Drizzle |
| Results that update when the data changes | BaseDb.liveQuery(), or useLiveQuery in React |
| Published records as JSON, local or remote | getPublishedSeedRecord() or @seedprotocol/query |
| Schemas and models | Schema.all() and Model.all(). See Schema and Model |
Item.all filters only by model, deleted state, whether to include on-chain items, and owned or watched addresses. For anything else, filter its results in your code, as Reading and writing data does, or query SQLite.
BaseDb.getAppDb
Returns the Drizzle database for the app's SQLite file.
static getAppDb(): anyBefore client.init() has set up the platform, it throws Db not configured. Call BaseDb.configure() during platform init. Call it after client.init() has resolved. After client.stop() it returns undefined until the next init opens the database. The return type is any, so query results aren't typed.
Import Drizzle's operators (eq, and, sql and the rest) from @seedprotocol/sdk/drizzle. It re-exports the drizzle-orm build the SDK queries with, so you don't install Drizzle or match its version.
The file is <filesDir>/db/seed.db in both the browser and Node. Storage lists the engine each uses and the Node dbConfig.dbUrl override.
Tables
The main entry exports the Drizzle table objects. The two you'll query most:
seeds: one row per item.
| Column | Description |
|---|---|
localId | The item's seedLocalId |
uid | The item's seedUid. Empty or null for drafts |
type | The model's EAS name (easName): 'post' for Post, 'blog_post' for BlogPost, 'html_page' for HTMLPage, 'image_v2' for ImageV2. Since 0.7.4: rows written earlier can hold other forms ('image_v_2', 'htmlpage'), and the SDK rewrites each one that matches exactly one of your models in the background after it loads the schema |
modelFileId | The model's ID, when several schemas define the same model name |
publisher | The publishing address, or null for unclaimed drafts |
createdAt, attestationCreatedAt | Creation and attestation times in milliseconds |
_markedForDeletion | 1 once the item is deleted |
revokedAt | Unix seconds when the item was unpublished |
metadata: one row per property value per version. Editing a property adds or updates a row on the item's current version, so a property can have several rows. An edit reaches this table only when its write applies, after the assignment returns, so await property.save() before you read metadata yourself.
| Column | Description |
|---|---|
seedLocalId, seedUid | The item |
versionLocalId, versionUid | The version the value belongs to |
propertyId | The property's row in the properties table. Null on rows the SDK couldn't match to one property |
propertyName | The property's storage name (below) |
propertyValue | The value as text |
localId, uid | The row's IDs. uid is set once the value is attested |
createdAt, attestationCreatedAt, revokedAt | Used to pick the current value |
New rows are stored under the property's storage name: the property's own name for most types (title), <name>Id for a single Relation, Image, File or Html property (authorId, bodyId), and a generated name for a list of relations, such as editorAuthorIds for editors, a list of Author. Rows written by an older SDK can hold the bare name (author, body). The SDK matches rows to their property by propertyId, so filter on it, or on every name the property's rows can have.
The other tables are versions, schemas, models, modelSchemas, properties, appState, modelUids, publishProcesses, uploadProcesses, easSyncProcesses, arweaveL1FinalizeJobs and htmlEmbeddedImageCoPublish. The database also has pending_writes (the outbox of edits waiting to be written) and entity_changes (the change feed's log), which aren't exported. All of them are the SDK's internal storage and can change between releases. Read them, but write through Item and ItemProperty.
Example: current titles of all posts
To find an item's current value, take its newest row per property. compareMetadataRowsLatestFirst sorts rows the same way the SDK does: newest first by attestation time, else creation time, with revoked rows counted like any other. On a published item, a revoked newest row with no value is a removed property, so an earlier version's value doesn't count.
import { BaseDb, compareMetadataRowsLatestFirst, metadata, seeds } from '@seedprotocol/sdk'
import { and, eq, isNull, or } from '@seedprotocol/sdk/drizzle'
const db = BaseDb.getAppDb()
const rows: Array<{
seedLocalId: string
title: string | null
localId: string | null
createdAt: number | null
attestationCreatedAt: number | null
revokedAt: number | null
}> = await db
.select({
seedLocalId: metadata.seedLocalId,
title: metadata.propertyValue,
localId: metadata.localId,
createdAt: metadata.createdAt,
attestationCreatedAt: metadata.attestationCreatedAt,
revokedAt: metadata.revokedAt,
})
.from(metadata)
.innerJoin(seeds, eq(seeds.localId, metadata.seedLocalId))
.where(
and(
eq(seeds.type, 'post'),
eq(metadata.propertyName, 'title'),
or(isNull(seeds._markedForDeletion), eq(seeds._markedForDeletion, 0)),
),
)
const titles = new Map<string, (typeof rows)[number]>()
for (const row of rows) {
const current = titles.get(row.seedLocalId)
if (!current || compareMetadataRowsLatestFirst(row, current) < 0) {
titles.set(row.seedLocalId, row)
}
}pickLatestMetadataRowPerProperty(rows) does the same for the rows of one item, keeping one row per property: rows group by propertyId, and a row without one joins the rows stored under its name.
BaseDb.liveQuery
Runs a query and emits its results again whenever they change.
static liveQuery<T>(query: ((sql: any) => any) | any): Observable<T[]>Parameters
| Name | Type | Description |
|---|---|---|
query | Drizzle query, or a function that receives SQLocal's sql tag | The query to watch. Pass an un-awaited Drizzle query such as db.select().from(seeds) |
Returns
An RxJS Observable of result rows. It emits once with the current results, then again each time they change. Rows are compared as JSON, so an unchanged result isn't emitted twice. Call unsubscribe() on the subscription to stop.
A query that fails sends the error to the subscriber. In the browser the SDK also logs it under the seedSdk:browser:db:Db debug namespace, not to the console.
Example
import { BaseDb, seeds } from '@seedprotocol/sdk'
import { eq } from '@seedprotocol/sdk/drizzle'
const db = BaseDb.getAppDb()
const subscription = BaseDb.liveQuery<{ id: string }>(
db.select({ id: seeds.localId }).from(seeds).where(eq(seeds.type, 'post')),
).subscribe((rows) => {
console.log(`${rows.length} posts`)
})
// later
subscription.unsubscribe()Browser and Node
| Browser | Node | |
|---|---|---|
| How it updates | SQLocal reactive queries: re-runs when a table the query reads changes | Re-runs the query every second |
| Drizzle query | Yes | Yes |
sql tag function, such as (sql) => sql`SELECT * FROM seeds` | Yes | No. liveQuery itself throws SQL tag functions are not supported in node liveQuery stub implementation. Use Drizzle query builders instead., not the observable |
In the browser, calls with the same SQL tag query and parameters return the same observable while it has subscribers, so they share one SQLocal query, and a late subscriber gets the last rows at once. Each Drizzle query builder gets its own.
Use Drizzle queries if your code runs in both environments. On Node the poll runs on a timer that keeps the process alive while a subscription is open. client.stop() (or BaseDb.close()) completes every open subscription, which ends its timer.
In React, useLiveQuery from @seedprotocol/react wraps liveQuery and handles subscribing and cleanup. See Live queries and Querying with liveQuery.
BaseDb.transaction
Runs fn in one transaction and resolves with its result.
static transaction<T>(fn: (tx: any) => Promise<T>): Promise<T>tx is a Drizzle database, used like getAppDb(). Every query fn runs on tx commits together, or none does when fn throws, and the transaction rejects with what fn threw. When another tab holds the database lock (SQLITE_BUSY), the whole transaction runs again, up to five more times over about a second. So:
fnmust have no side effects outsidetx.- Don't call
BaseDb.getAppDb()orBaseDb.transactioninsidefn. Queries on the app database wait for the open transaction to end, sofnwould wait for itself.
On Node the SDK keeps one connection and runs one transaction at a time; other statements wait for it.
import { BaseDb, metadata, seeds } from '@seedprotocol/sdk'
const counts = await BaseDb.transaction(async (tx) => ({
items: (await tx.select().from(seeds)).length,
values: (await tx.select().from(metadata)).length,
}))Other BaseDb methods
| Method | Description |
|---|---|
BaseDb.isAppDbReady() | false before client.init(), then true once the database opens, and false again after client.stop(), in the browser and on Node. Never throws. To wait for the database instead, use whenDbReady() |
BaseDb.close() | Closes the app database and forgets it, resolving once it's closed: in the browser the SQLite worker ends and the change feed stops; on Node the libSQL client closes. getAppDb() then returns undefined and isAppDbReady() is false until the next init. A prepareDb still running when you call it closes what it opened and throws The database was closed while it was being opened. Does nothing when no database is open. client.stop() calls it; to close the database, call client.stop(), which also stops what uses it |
BaseDb.prepareDb(filesDir), connectToDb, migrate, configure | Called by client.init(). Don't call them yourself |
Other read helpers
These functions read SQLite, and file sizes where noted, and change nothing:
| Function | Returns |
|---|---|
getSeedPublishState(item) | Whether an item is a draft or on-chain, with its attestation UIDs. See Item |
getPublishPendingDiff(item) | { pendingProperties, lastPublishedVersionUid, lastVersionPublishedAt }. Each pending property has propertyName (its storage name, such as authorId), currentValue (a value waiting in the outbox counts), previousPublishedValue, pending, and change: 'set' for a new value, 'removed' for an empty value the next publish attests, 'retracted' for one whose attestations it revokes. A property set back to its previousPublishedValue isn't listed, even before that write is applied |
getPublishGraph(item, options?) | The items, files and embedded images the next publish of item attests, as a PublishGraph (root, then nodes in publish order with the root last). options.publishMode is 'patch' (default) or 'new_version'. options.backReferences decides what happens to a back reference, a relation to an item the publish is still creating: 'defer' (default) marks the property deferred and leaves it out, along with a whole List holding one; 'link' keeps it, with the target's local ID for the publish to fill in. Publish uses 'link' for a contract publish, and direct EAS stays on 'defer'. Waits first for the pending outbox writes on the graph's items to apply, with no time limit; writes to other items don't hold it. When any were pending it reads the graph again, up to four times |
summarizePublishUploads(item, options?) | What a publish of item would upload, as a PublishUploadSummary: uploadCount, uploadBytes, uploadSizes (bytes per upload) and unpublishedRelatedItems, the references to items whose attestation was revoked, which fail a publish with RelatedItemUnpublishedError. item needs seedLocalId and optionally modelName. options.publishMode is 'patch' (default) or 'new_version'. Reads the graph as getPublishGraph does, and file sizes without reading the files. Main entry only |
getPendingRetractions(item) | The item's retracted properties that still have attestations to revoke, as PendingRetraction[]: { propertyName, attestations: { uid, schemaUid }[] } |
isItemOwned(item) | Whether the current user can change the item. See Ownership |
getVersionsForSeedUid(seedUid) | The item's version UIDs, as { uid }[] |
getPublishModels(modelNames, options?) | The definitions a publish of those models attests, from the loaded schemas, as PublishModel[]: { modelName, properties: { key, definition, kind }[] }, where kind is a PublishPropertyKind. Lists each named model, the Html, Image and File storage models its properties use, and, with followRefs (default true), the models its Relation and List properties name, and theirs in turn. Each model is listed once. options.schemaName picks the schema when two define the name. Takes model names, not an item. Throws UnknownModelError for an unknown model (Model "Nope" not found., or Model "Post" not found in schema "other". with schemaName), and AmbiguousModelError for a name two schemas define without schemaName. Publish's ensureEasSchemasForModels registers the EAS schemas for the same set (publish overview) |
Each takes an item or { seedLocalId, seedUid } unless shown otherwise. markPropertyAttestationsRetracted({ uids, revokedAt }) is the one write in this group: @seedprotocol/publish calls it after revoking a retracted property's attestations, and it marks their rows revoked and empties them. The main entry also exports lower-level readers used by @seedprotocol/react and @seedprotocol/publish, such as getMetadataLatest and getVersionData. They return Drizzle subqueries and aren't covered here.
Change feed
In the browser, every write to seeds, versions and metadata is logged in entity_changes, and each tab reads new log rows when they appear, including rows another tab wrote. Loaded items and properties use it to re-read only their own rows; Reactivity model explains how. The feed's functions are exported for code that keeps its own copy of an item's rows. On Node there's no feed: isChangeFeedRunning() returns false and listeners are never called. Writes are still logged in entity_changes there, and the SDK deletes rows older than 10 minutes when it opens the database and every minute after.
type EntityChangeTable = 'seeds' | 'versions' | 'metadata'
type ChangeWatch = {
seedLocalId?: string | null
seedUid?: string | null
tables: EntityChangeTable[]
propertyNames?: string[]
}
function watchEntityChanges(watch: ChangeWatch, listener: () => void): () => void
function watchEntityRows<T>(
watch: ChangeWatch,
read: () => Promise<T>,
onRows: (rows: T) => void,
options?: { immediate?: boolean; onError?: (error: unknown) => void },
): { unsubscribe: () => void }
function isChangeFeedRunning(): booleanwatchEntityChanges calls listener after a committed write to one of watch.tables for the watched item, matched by seedLocalId or by seedUid (ignoring case). With propertyNames, only metadata rows of those storage names count. Several matching writes found in one read of the log call it once. It returns a function that stops the watch.
watchEntityRows runs read each time the watch fires and passes the result to onRows when it differs, as JSON, from the last result passed. A read that finishes after a newer one started is dropped. immediate: true also runs read once right away. A failed read goes to onError, or to the debug log without one.
import { BaseDb, metadata, watchEntityRows } from '@seedprotocol/sdk'
import { eq } from '@seedprotocol/sdk/drizzle'
const db = BaseDb.getAppDb()
const watch = watchEntityRows(
{ seedLocalId: post.seedLocalId, tables: ['metadata'] },
() => db.select().from(metadata).where(eq(metadata.seedLocalId, post.seedLocalId)),
(rows) => updateSearchIndex(post.seedLocalId, rows),
{ immediate: true },
)
// later
watch.unsubscribe()The leader tab deletes log rows older than 10 minutes. A tab that falls further behind than that calls every listener once.
Published data
Item and liveQuery read your local drafts and local copies. To read published records as JSON, with relations and file content resolved, use @seedprotocol/query. client.init() registers the SDK's database as its local source, so its queries can answer from local data first.
The SDK exports one shortcut:
function getPublishedSeedRecord(
seedUid: string,
options?: GetPublishedSeedRecordOptions,
): Promise<GetSeedResult | null>It calls getSeed from @seedprotocol/query. options.source defaults to 'local', which reads only the local database. Pass 'auto' to fall back to remote sources. It returns null when no record is found. The other options are getSeed's, documented in @seedprotocol/query.
import { getPublishedSeedRecord } from '@seedprotocol/sdk'
const record = await getPublishedSeedRecord(post.seedUid!, { source: 'auto' })registerSeedQueryLocalSource(), unregisterSeedQueryLocalSource(), isSeedQueryLocalSourceRegistered() and createLocalQueryDataSource() manage that registration. client.stop() and client.unload() unregister the source, and getPublishedSeedRecord registers it again when it isn't registered and the database is ready. See Reading published data.
Related
- Item:
Item.allandItem.findin full - Reactivity model: how the change feed keeps loaded items current
- Querying with liveQuery
- React live queries:
useLiveQuery - @seedprotocol/query and its adapters
- Reading and writing data
ItemProperty
The ItemProperty class: one property value of an item, how assigning to it writes, how save() reports errors, clearing a value, conflicts after sync, and how file, image, relation and list values behave.
Events
The SDK's event bus: how to listen for saves, conflicts, EAS syncs, address changes and file changes, every event name with its payload, and which events reach other tabs.