Persistent state
When in-memory isn't enough: plugin KV and richer data, with Drizzle.
Before you pick a tool, separate the two data shapes you'll store:
| Shape | Example | What it needs |
|---|---|---|
| Plugin state (KV) | lastTradeMs, cooldowns, "I'm holding position X" | Small, written every tick, scoped per plugin. Sub-ms reads. |
| External data (rich schema) | Price history, signals, a trade log for analytics | Larger, possibly time-series, several related tables. |
PluginStateAPI (ctx.state.get/set) is for the first shape only. The second goes into a separate pipeline your plugin owns. The same DB usually serves both, in different tables.
Recommended stack: Drizzle ORM
Drizzle is what the Automark monorepo uses internally. It's TypeScript-first (the schema is plain TS, with errors at compile time), cross-DB (Postgres, SQLite, libSQL/Turso behind one query API), and lightweight (one dep plus the driver), with optional migrations via drizzle-kit.
It's overkill when the plugin only writes a couple of KV entries per tick; the raw client (pg / better-sqlite3) does that in five lines.
pnpm add better-sqlite3 # SQLite: file-based, zero infra
pnpm add pg # Postgres: production
pnpm add drizzle-orm # ORM (same across drivers)
pnpm add -D drizzle-kit # optional: migrationsState adapter with Drizzle (SQLite)
The schema is deliberately small: one table with a composite key.
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core";
const sqlite = new Database(process.env.STATE_DB_PATH ?? "./runner-state.db");
sqlite.exec(`
CREATE TABLE IF NOT EXISTS plugin_state (
plugin_name TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
expires_at INTEGER, -- null = no TTL; otherwise ms-epoch deadline
PRIMARY KEY (plugin_name, key)
);
`);
export const db = drizzle(sqlite);
export const pluginState = sqliteTable("plugin_state", {
pluginName: text("plugin_name").notNull(),
key: text("key").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at"), // null = no TTL
}, (t) => ({ pk: primaryKey({ columns: [t.pluginName, t.key] }) }));The DrizzleStateAPI class implements PluginStateAPI (get/set/setWithTTL/delete/has/keys). Every read filters for "not expired" (expires_at null or in the future). That hides expired rows but leaves them on disk, so for long-running deploys run a periodic job deleting WHERE expires_at <= now.
Wiring into the runtime
const runtime = new Runner({
client, signer, logger, predictRefs,
stateFactory: (pluginName) => new DrizzleStateAPI(pluginName),
});External data
For anything richer than KV (history, signals, an audit log), define a real schema. Drizzle's queries stay type-safe.
import { pgTable, text, bigint, timestamp, real, index } from "drizzle-orm/pg-core";
export const priceHistory = pgTable("price_history", {
asset: text("asset").notNull(),
tsMs: bigint("ts_ms", { mode: "bigint" }).notNull(),
spotUsd: real("spot_usd").notNull(),
sviSigma: real("svi_sigma").notNull(),
}, (t) => ({ byAssetTs: index("price_history_asset_ts_idx").on(t.asset, t.tsMs) }));In decide(), you write the live price and read the last N to compute a moving average, all in the same DB.
Mixing backends
stateFactory receives pluginName, so you can discriminate. High-frequency strategies use InMemoryStateAPI (a restart wipe is tolerable); the rest persist.
stateFactory: (pluginName) => {
if (pluginName.startsWith("scalping-")) return new InMemoryStateAPI();
return new DrizzleStateAPI(pluginName);
},Choosing the DB
| DB | Best for |
|---|---|
SQLite (better-sqlite3) | Local dev, single-process plugin, file-based persistence |
Postgres (pg) | Production, multi-process, shared infra, JSONB |
| libSQL / Turso | Edge deploys, replicated SQLite-compatible |
Drizzle abstracts the query API but not the schema (sqliteTable and pgTable have different column types). Plan your target DB. Moving down from the more expressive one (Postgres) is harder than moving up from the simpler one (SQLite).