Skip to main content

Database

Ash supports two database backends behind a common interface: SQLite (default) for single-machine deployments and PostgreSQL/CockroachDB for multi-machine setups.

Configuration

Environment VariableDefaultDescription
ASH_DATABASE_URLNot set (uses SQLite)PostgreSQL or CockroachDB connection URL

When ASH_DATABASE_URL is not set, Ash creates a SQLite database at data/ash.db. When set to a postgresql:// or postgres:// URL, Ash connects to the specified Postgres-compatible database.

Backend Selection

The initDb() factory function selects the backend based on the URL:

export async function initDb(opts: { dataDir: string; databaseUrl?: string }): Promise<Db> {
if (opts.databaseUrl && /^postgres(ql)?:\/\//.test(opts.databaseUrl)) {
const pgDb = new PgDb(opts.databaseUrl);
await pgDb.init();
return pgDb;
} else {
return new SqliteDb(opts.dataDir);
}
}

Common Interface

Both backends implement the same Db interface:

interface Db {
// Agents
upsertAgent(name, path, tenantId?): Promise<Agent>;
getAgent(name, tenantId?): Promise<Agent | null>;
listAgents(tenantId?): Promise<Agent[]>;
deleteAgent(name, tenantId?): Promise<boolean>;

// Sessions
insertSession(id, agentName, sandboxId, tenantId?, runnerId?, model?): Promise<Session>;
updateSessionStatus(id, status): Promise<void>;
getSession(id): Promise<Session | null>;
listSessions(tenantId?, agent?): Promise<Session[]>;
touchSession(id): Promise<void>;
// ... plus updateSessionSandbox, updateSessionRunner, listSessionsByRunner

// Sandboxes
insertSandbox(id, agentName, workspaceDir, sessionId?, tenantId?): Promise<void>;
updateSandboxState(id, state): Promise<void>;
getSandbox(id): Promise<SandboxRecord | null>;
countSandboxes(): Promise<number>;
getBestEvictionCandidate(): Promise<SandboxRecord | null>;
getIdleSandboxes(olderThan): Promise<SandboxRecord[]>;
markAllSandboxesCold(): Promise<number>;
// ... plus updateSandboxSession, touchSandbox, deleteSandbox

// Messages
insertMessage(sessionId, role, content, tenantId?): Promise<Message>;
listMessages(sessionId, tenantId?, opts?): Promise<Message[]>;

// Session Events
insertSessionEvent(sessionId, type, data, tenantId?): Promise<SessionEvent>;
insertSessionEvents(events): Promise<SessionEvent[]>;
listSessionEvents(sessionId, tenantId?, opts?): Promise<SessionEvent[]>;

// API Keys
getApiKeyByHash(keyHash): Promise<ApiKey | null>;
insertApiKey(id, tenantId, keyHash, label): Promise<ApiKey>;

// Lifecycle
close(): Promise<void>;
}

SQL Dialect Differences

FeatureSQLitePostgres
Timestampsdatetime('now')now()::TEXT
UpsertON CONFLICT(...) DO UPDATEON CONFLICT(...) DO UPDATE
Parameters? positional$1, $2 numbered
Connection modelSingle file, in-processConnection pool (pg.Pool)
Journal modeWALWAL (default in Postgres)
Column migrationtry/catch (no IF NOT EXISTS)ALTER TABLE ... ADD COLUMN IF NOT EXISTS
Sequence assignmentSELECT MAX(sequence) in transactionAtomic subquery in INSERT ... RETURNING

Connection Retry (Postgres)

The Postgres backend retries the initial connection with exponential backoff (1s, 2s, 4s, 8s, 16s -- five attempts total, ~31 seconds). This handles common startup races where the database container is not yet ready.

[db] Connection attempt 1 failed, retrying in 1000ms...
[db] Connection attempt 2 failed, retrying in 2000ms...

Tables

agents

CREATE TABLE agents (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
path TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(tenant_id, name)
);

sessions

CREATE TABLE sessions (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
agent_name TEXT NOT NULL,
sandbox_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'starting',
runner_id TEXT,
model TEXT,
created_at TEXT NOT NULL,
last_active_at TEXT NOT NULL
);

sandboxes

CREATE TABLE sandboxes (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
session_id TEXT,
agent_name TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'warming',
workspace_dir TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT NOT NULL
);

messages

CREATE TABLE messages (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
sequence INTEGER NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(tenant_id, session_id, sequence)
);

session_events

CREATE TABLE session_events (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
session_id TEXT NOT NULL,
type TEXT NOT NULL,
data TEXT,
sequence INTEGER NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(tenant_id, session_id, sequence)
);

api_keys

CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);

Production Recommendation

For single-machine deployments, SQLite with WAL mode is sufficient and requires no external dependencies. For multi-machine deployments (coordinator + runners sharing state), use PostgreSQL or CockroachDB so all nodes share the same database.