Use Seed in Node and Electron
Run the SDK in Node scripts, servers and Electron apps: the working directory, ESM and CommonJS, where data lives on disk, ending scripts, and what's different from the browser.
The SDK runs on Node as well as in the browser, with the same client, Item and query APIs. On Node it keeps its database in a libSQL file and its files on disk, which makes it useful for scripts that seed or export data, servers that read a user's published content, tests, and Electron apps.
You'll need:
- Node 20.19 or a later 20.x, or Node 22.12 through 24 (the SDK's
enginesfield is^20.19.0 || >=22.12.0 <=24) - The
blogschema from Your first schema, saved asseed.schema.jsonin the project root
Install
npm install @seedprotocol/sdkNode needs no ZenFS, Vite plugin or cross-origin headers.
ESM or CommonJS
Both work. ES modules are the package's own format, so name your files .mjs or set "type": "module" in package.json if you can. From CommonJS, require works too:
const { client, Item } = require('@seedprotocol/sdk')There's no separate CommonJS build: require loads the same ES module files through Node's require(esm) support, which is why the SDK needs Node 20.19+ or 22.12+. Code that mixes require and import shares one client.
In TypeScript, the SDK's declarations resolve under "moduleResolution": "bundler", "nodenext" and "node16". Keep skipLibCheck on: without it, TypeScript reports errors inside drizzle-orm (Entry points).
The working directory
With an absolute filesDir, client.init writes nothing to the working directory, so any working directory works, including a read-only one. A relative filesDir, the default '.seed' included, resolves under <cwd>/.seed (see Choose where the data lives), which the SDK creates when it opens the database. Then the working directory has to be writable, or client.init rejects:
Error: EACCES: permission denied, mkdir '/…/.seed/blog-data'If something else picks the working directory (a process manager, a container, Electron), pass an absolute filesDir. The SDK finds its database migrations in its own installed package.
The loader hook, for @seedprotocol/publish
The SDK itself needs no flags. @seedprotocol/publish still does: it imports the EAS SDK, which does import { isEqual } from 'lodash', and Node rejects that:
SyntaxError: The requested module 'lodash' does not provide an export named 'isEqual'In a process that loads publish, register the SDK's hook, which points that one import at lodash-es. On the command line:
node --import @seedprotocol/sdk/node-eas-lodash scripts/publish.mjsOr in code, before publish loads. Node resolves every static import of a module before running any of them, so load publish with a dynamic import() after the hook:
import '@seedprotocol/sdk/node-eas-lodash'
const { initPublish } = await import('@seedprotocol/publish')NODE_OPTIONS="--import @seedprotocol/sdk/node-eas-lodash" works too, for tools that start Node for you.
Choose where the data lives
filesDir decides where the database (<filesDir>/db/seed.db) and files go. Without it, everything goes in <cwd>/.seed. A relative filesDir such as 'blog-data' ends up under <cwd>/.seed too, so the data moves with the working directory; for servers and desktop apps, pass an absolute path such as '/var/lib/my-app'. filesDir and endpoints has the resolution rules, including the exception for a single-segment absolute path that doesn't exist.
To put the database file somewhere else, set dbConfig.dbUrl to its path; the SDK creates the folder if it doesn't exist. dbConfig.outDir is the folder the SDK copies its migrations to and runs them from, and also holds the database when dbUrl isn't set. Stored files and the schema copy stay under filesDir.
Configure the client
client.init takes the same options as in the browser. Only schema is required, and a schema given as a path is resolved from the working directory.
await client.init({
config: { filesDir: '/var/lib/my-app', schema },
addresses: { owned: ['0x…'] }, // optional: whose published items to sync
})- One client per process. Init runs once per config. A second call with the same config resolves immediately and ignores its
addresses;client.setAddresseschanges them. A call with a different config throwsclient.init was already called with a different config…. To change the config,await client.stop()first. - Addresses are kept between starts. An init without
addresseskeeps the stored ones and logs a one-time warning;addresses: []clears them. See Addresses. - New drafts get the owned address when there's only one. With one owned address set, a script needs nothing else to own the drafts it creates. With several, register a publisher with
setGetPublisherForNewSeedsbefore creating items, as Addresses shows; otherwise new drafts have no publisher, which leaves them editable untilclient.claimUnpublishedDrafts(address)gives them one. Drafts created before any owned address was set also stay without a publisher until claimed. - One process per database. The locks that keep browser tabs from stepping on each other are browser-only. Two Node processes opening the same
filesDiraren't coordinated: each runs its own syncs, and the last write wins.
Everything in Client and configuration applies, including config.eas and the EAS_ENDPOINT/DEBUG environment variables. Turn on the SDK's logs with DEBUG=seedSdk:*. A schema that can't be loaded makes client.init reject with Could not load config.schema… and the reason.
On Node the SDK sends EAS requests through NodeEasClient from @seedprotocol/eas, which keeps its own connection pool with idle connections open for 30 seconds. To use fetch's global dispatcher instead, install @seedprotocol/eas at the SDK's version and, after client.init, call EasClient.configure(new NodeEasClient({ dispatcher: false })), with EasClient from @seedprotocol/sdk and NodeEasClient from @seedprotocol/eas/node.
Read and write
The Item API works as in the browser. Two habits matter more in a script, which tends to exit as soon as its last line runs:
- Wait for saves. Assigning a property starts a save in the background.
awaitthe property'ssave()before you read the database another way or exit. - Stop the client. The process exits on its own once its code has finished, unless something is still running: an open
liveQuerysubscription polls every second and keeps it alive.await client.stop()when the script is done with the client; it completes every open subscription, which ends its poll.
Live queries poll
On Node, BaseDb.liveQuery re-runs its query every second and emits when the result changes. It only accepts a Drizzle query builder; an sql tag function throws SQL tag functions are not supported in node liveQuery stub implementation. See Querying.
Complete example
This script creates an author if there isn't one, adds a post, edits it, lists every post and watches the database for a moment.
// Run from the project root, where seed.schema.json is:
// node scripts/blog.mjs "My first post"
import { readFile } from 'node:fs/promises'
import { client, Item, BaseDb, seeds } from '@seedprotocol/sdk'
const schema = JSON.parse(await readFile('seed.schema.json', 'utf8'))
await client.init({ config: { schema } })
// Reuse the first author, or create one.
const authors = await Item.all('Author', false, { waitForReady: true })
const author =
authors[0] ?? (await Item.create({ modelName: 'Author', name: 'Ada', bio: 'Writes things' }))
// Create a post that points at the author.
const title = process.argv[2] ?? 'Hello from Node'
const values = { modelName: 'Post', title, tags: ['node'], author: author.seedLocalId }
const post = await Item.create(values)
// Edit it, and wait for the save to finish before moving on.
post.summary = `Written at ${new Date().toISOString()}`
await post.properties.find((p) => p.propertyName === 'summary')?.save()
// List every local post.
const posts = await Item.all('Post', false, { waitForReady: true })
for (const p of posts) {
console.log(`${p.seedLocalId} ${p.title} (${p.summary ?? 'no summary'})`)
}
// Watch the seeds table. On Node this re-runs the query every second.
const db = BaseDb.getAppDb()
const subscription = BaseDb.liveQuery(db.select().from(seeds)).subscribe((rows) => {
console.log(`${rows.length} items in the database`)
})
setTimeout(async () => {
subscription.unsubscribe()
await client.stop()
}, 1500)Run it twice:
node scripts/blog.mjs "First"
node scripts/blog.mjs "Second"The second run prints both posts, newest first, and the item count (an author and two posts):
BdrSODlTmT Second (Written at 2026-10-16T12:13:06.452Z)
JGdk22hIDM First (Written at 2026-10-16T12:12:58.782Z)
3 items in the databaseEach run takes several seconds. The database is now at .seed/db/seed.db, next to a copy of the schema the SDK wrote.
Item.all without includeEas: true lists local drafts only. Once you sync published items, pass includeEas: true to see them too.
Serve data from a Node server
Start the client once, before the server accepts requests, and share it across requests:
import http from 'node:http'
import { readFile } from 'node:fs/promises'
import { client, Item } from '@seedprotocol/sdk'
const schema = JSON.parse(await readFile('seed.schema.json', 'utf8'))
// Start the client once, before accepting requests.
await client.init({
config: { filesDir: process.env.SEED_DATA_DIR, schema },
addresses: process.env.SEED_ADDRESS ? { owned: [process.env.SEED_ADDRESS] } : undefined,
})
const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/posts') {
const posts = await Item.all('Post', false, { includeEas: true, waitForReady: true })
const body = posts.map((p) => ({ id: p.seedUid ?? p.seedLocalId, title: p.title, summary: p.summary }))
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
return
}
res.writeHead(404).end()
})
server.listen(3000, () => console.log('Listening on http://localhost:3000/posts'))
process.on('SIGINT', async () => {
server.close()
await client.stop()
process.exit(0)
})Without SEED_DATA_DIR the data goes in <cwd>/.seed; in production, set it to an absolute path. With SEED_ADDRESS set, the start-up sync pulls in that account's published posts. If that start-up sync fails, for example because the indexer can't be reached, the SDK retries it on a backing-off timer (see Offline retries). Nothing else syncs on its own, so call client.syncFromEas() on a timer, or from an admin endpoint, if the server should pick up new posts. That call isn't retried for you: it throws when the indexer can't be reached, so catch it and try again later. Syncing across devices covers sync triggers. For serving published records to other apps, see Serving a feed.
What's different on Node
| Feature | On Node |
|---|---|
| Database | libSQL file at <filesDir>/db/seed.db, or at dbConfig.dbUrl |
| Live queries | Poll every second; Drizzle query builders only |
| Loaded items hearing other writes | No change feed. Loaded items are read again after each EAS sync; writes from other processes, or your own SQL, reach them only when they're loaded again |
| Multi-tab coordination | None. The process is always the leader |
| EAS sync | Works, including the start-up and address-change syncs. A failed start-up or automatic sync is retried on a timer |
| Downloading files of synced items | Doesn't run. Synced items' images, files and HTML bodies aren't fetched from Arweave |
FileManager.downloadAllFiles, resizeImage, resizeAllImages | Throw Not implemented |
ensureImageLocal | Returns { status: 'unsupported' } |
Files you save through Image, File and Html properties | Written to disk under filesDir |
Interrupted publishes (@seedprotocol/publish) | Restored once the publish manager starts: on await PublishManager.ready() or the first item.publish(). It doesn't start on its own |
Electron
The SDK picks its platform when client.init runs. In Electron it treats any context where it can see Node's process as Node: the main process, utility processes, and renderers with nodeIntegration. A renderer without Node access (the default: context isolation, no Node integration) is treated as a browser.
Recommended: run the SDK in the main process
Keep the SDK in the main process (or a utility process) and let the renderer reach it over IPC. The main process is a normal Node environment, so everything above applies, and one process owns the database.
The one thing that needs care is filesDir. A packaged app's working directory is often / or a read-only app bundle, where a relative filesDir can't be created. Pass an absolute path in the app's data folder:
import path from 'node:path'
import { client } from '@seedprotocol/sdk'
// Call from app.whenReady(), with app.getPath('userData').
export async function startSeed(userData, schema) {
// An absolute filesDir, so nothing is written to the working directory,
// which is often read-only in a packaged app.
await client.init({ config: { filesDir: path.join(userData, 'seed'), schema } })
return client
}The SDK needs no loader hook. If the main process also loads @seedprotocol/publish, import @seedprotocol/sdk/node-eas-lodash first and load publish with a dynamic import(), as in The loader hook. Electron bundles its own Node version, so check process.versions.node in yours: it has to be in the SDK's engines range, and require needs 20.19+ or 22.12+. @libsql/client loads a native binary, so make sure your packager keeps *.node files outside the asar archive (most do by default).
Renderer with Node integration
It works the same way as the main process, with the same filesDir advice. It isn't recommended: each renderer with Node integration opens the database itself, uncoordinated, and after a sync the SDK tries the browser's file download path, which the Node file manager doesn't implement, so synced files aren't downloaded (the error goes to the debug log).
Renderer as a browser
A renderer without Node access runs the browser setup: SQLite in OPFS, ZenFS and the Vite plugin, as in Add Seed to your app. The page needs to be cross-origin isolated, as it does on the web; without that, client.init rejects with DatabaseNotPersistentError. The Vite plugin sets the COOP and COEP headers in development; in a packaged app, your app has to send them with the renderer's page itself. The data then lives in the renderer's OPFS storage, which only that renderer's origin can reach.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
The requested module 'lodash' does not provide an export named 'isEqual' | @seedprotocol/publish was loaded without the loader hook, or imported statically next to it | Use --import @seedprotocol/sdk/node-eas-lodash, or import the hook and then await import('@seedprotocol/publish') |
client.init rejects with EACCES: permission denied, mkdir '<cwd>/.seed/…' | filesDir is relative and the working directory isn't writable | Pass an absolute filesDir |
require() of an ES module fails | Node is older than 20.19 (or 22.12 on the 22 line) | Upgrade Node, or use import |
client.init was already called with a different config… | A second client.init with a different config, for example after a hot reload edited it | await client.stop() before initialising with the new config, or restart the process |
Could not load config.schema from "…": … | The schema file is missing or invalid | Fix the schema; the message gives the reason |
SQL tag functions are not supported in node liveQuery stub implementation | BaseDb.liveQuery was given an sql tag function | Pass a Drizzle query builder |
| The script never exits | A liveQuery subscription is still open | await client.stop(), which completes it, or call its unsubscribe() |
An assignment throws Cannot set <property> on <Model> <seedLocalId>: this Item instance was unloaded. …, or a property's save() throws … this ItemProperty instance was unloaded. … | The script kept an item that nothing holds for longer than the grace period (entityCache.unloadGraceMs, 60 seconds by default), or kept it across client.stop(). err.name is 'EntityUnloadedError' | Look the item up again. To keep one across long work, look it up with hold: true and call unload() when done (unload) |
An assignment doesn't stick, the property shows its old value, and the save throws OwnershipError | The item isn't owned: its publisher is an address that isn't in the owned addresses | Add its publisher to addresses.owned if that address is yours; otherwise the item is read-only here |
Related
- Node entry point:
@seedprotocol/sdk/node. For Next.js, see@seedprotocol/next - Client and configuration: every
client.initoption, and thefilesDirrules on Node - Querying:
BaseDb.getAppDbandliveQuery - Local-first and offline: what the client does with the network
- Guides: Syncing across devices, Serving a feed, Querying with live queries
Import an RSS feed into your models
Turn RSS or Atom items (or Markdown files) into Seed items with @seedprotocol/mapping: parse the source, map its fields onto model properties, resolve relations, and let users edit the mapping with the FieldMapper component.
Build a publishing app
A small blog app with authors and posts: a list that tracks each post's publish status, an editor that saves as you type, a cover image, publish and unpublish, and reading the published posts back from another app.