Skip to content
Runner · Examples

Persistent state

When in-memory isn't enough: plugin KV and richer data, with Drizzle.

Derived from runner/notes/persistent-state.md

Before you pick a tool, separate the two data shapes you'll store:

ShapeExampleWhat 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 analyticsLarger, 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.

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: migrations

State 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

DBBest for
SQLite (better-sqlite3)Local dev, single-process plugin, file-based persistence
Postgres (pg)Production, multi-process, shared infra, JSONB
libSQL / TursoEdge 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).