DB drivers

Point crust's sql builtin at Postgres, MySQL, SQLite, or anything you can wire up via init.ts.

The sql builtin reads $DATABASE_URL and instantiates a Bun SQL client at first call:

const SQL = (Bun as { SQL?: new (url: string) => unknown }).SQL;
if (SQL) return new SQL(url);
return Bun.sql; // fallback for builds that ship Bun.sql.unsafe instead

That makes driver choice = URL scheme. Set the right $DATABASE_URL once per session (or in ~/.config/crust/init.ts), and every sql "..." stage uses it.

For databases Bun.SQL doesn’t speak natively — SQLite, ClickHouse, DuckDB — register a custom crust.fn in init.ts. You keep the same shell-line ergonomics; only the connector underneath changes.

At a glance

EngineURL schemeHow crust connectsNotes
Postgrespostgres:// / postgresql://new Bun.SQL(url)First-class. Recommended for most workflows.
MySQL / MariaDBmysql://new Bun.SQL(url)Requires a recent Bun build with MySQL support compiled in.
SQLite (file)n/a (use bun:sqlite)crust.fn("sql", ...) shim in init.tsBun.SQL doesn’t speak SQLite. Wire it up yourself in 10 lines.
Anything elseany URLcrust.fn("sql", ...) in init.tsDuckDB, ClickHouse, Snowflake — bring the npm client of your choice.

Postgres (default)

The path of least resistance. Bun ships a native Postgres client.

export DATABASE_URL='postgres://app:app@localhost:5432/app'

# Quick health check — assert fails the line (exit 1) if it's not ok
sql "select 1 as ok" | assert (r => r.ok === 1)

# Stream rows downstream
sql "select id, email from users limit 5" | (r => r.email)

# Parameterized
sql "select * from users where tier = ?" "pro" | (r => r.id)

Connection pooling

Bun.SQL pools connections by default. Crust builds the client the first time sql runs in a process, reading $DATABASE_URL at that moment, and caches it for the rest of the session — there is no reset hook. Pointing at a different database (credentials rotated, a second schema) means a new process: a fresh crust -c '…', or a new REPL.

For more than one database in the same session, skip the sql builtin and open your own pool — a lambda stage is plain TypeScript running inside crust, so it can use it directly:

// init.ts — a second pool, reachable from any crust line via a global
import { SQL } from "bun";
const reporting = new SQL(process.env.REPORTING_URL!);
globalThis.watch = (email: string) =>
  reporting.unsafe("insert into watchers (email) values ($1)", [email]);

Then lines users.csv | (email => watch(email)) writes to the second database while sql keeps using $DATABASE_URL.

SSL / TLS

Append ?sslmode=require (or ?sslmode=verify-full) to your DATABASE_URL. The Bun client honors Postgres SSL params:

export DATABASE_URL='postgres://app:secret@db.example.com:5432/app?sslmode=require'

For client certificates use ?sslrootcert=...&sslcert=...&sslkey=... — paths are absolute or relative to $PWD.

MySQL / MariaDB

Same builtin, different URL scheme. Requires a Bun build with MySQL support — check bun --revision and bunx bun-info if sql "select 1" errors out with “no Bun.SQL”.

export DATABASE_URL='mysql://app:app@localhost:3306/app'

sql "select id, email from users limit 5" | (r => r.email)

# MySQL uses ? placeholders, not $1
sql "select * from users where tier = ?" "pro" | (r => r.id)

Write ? in a crust line; use the engine’s own style in TypeScript. Inside sql "…" the placeholder is always ?: crust rewrites it to $1, $2, … for postgres:///postgresql:// URLs and leaves sqlite:// and mysql:// queries byte-for-byte as written, because ? is already their placeholder. Writing $1 there is not a bind at all — $N in a registered-fn argument is the script’s positional argument, expanded into the SQL as text before the driver sees it (measured: sql "SELECT '$1' AS v" with argument X returns {"v":"X"}, and with O'Hara it dies on unterminated quoted string; the ? bind returns {"v":"O'Hara"} intact). In TypeScript you are calling the driver, so use its native style — Bun.sql’s template tag converts for you.

SQLite (via bun:sqlite)

Bun.SQL doesn’t speak SQLite — but bun:sqlite does, and you can shim it under the same shell-line surface in init.ts. The shim below replaces the built-in sql for the rest of the session.

// ~/.config/crust/init.ts
import { Database } from "bun:sqlite";

const db = new Database(process.env.SQLITE_PATH ?? "./app.sqlite");

crust.fn("sql", (...args: unknown[]) => {
  // Source mode:    sql "select ..." [params]      → args[0] is the query string
  // Transform mode: ... | sql "select ..." [params] → args[0] is the upstream item
  const first = args[0];
  const isSource = typeof first === "string";
  const query = isSource ? first : String(args[1] ?? "");
  const params = isSource ? args.slice(1) : args.slice(2);
  if (!query) throw new Error("sql: missing query");
  return db.query(query).all(...params);
});

Then from the shell:

export SQLITE_PATH=./app.sqlite

sql "select count(*) as n from users" | (r => r.n)

# SQLite uses ? placeholders
sql "select * from users where tier = ?" "pro" | (r => r.id)

The shim above keeps sql row-streaming behavior consistent — function-as-source flattens arrays into per-row items downstream, so sql "select ..." | (r => r.email) works the same as on Postgres.

Multiple databases in one session

You’ll often want a primary plus a read replica, or prod-read plus a local sandbox. Register named functions in init.ts; they all coexist.

// ~/.config/crust/init.ts
const primary = new Bun.SQL(process.env.PRIMARY_URL!);
const replica = new Bun.SQL(process.env.REPLICA_URL!);

const make = (client: Bun.SQL) => async (...args: unknown[]) => {
  const first = args[0];
  const isSource = typeof first === "string";
  const query = isSource ? first : String(args[1] ?? "");
  const params = isSource ? args.slice(1) : args.slice(2);
  return await client.unsafe(query, params);
};

crust.fn("psql", make(primary));
crust.fn("rsql", make(replica));

Now the prompt has two namespaced stages:

# Write to the primary
psql "insert into events (kind, payload) values ($1, $2)" "click" '{"x":1}'

# Read from the replica
rsql "select id, kind from events order by id desc limit 10" | (e => e.id)

Other engines via npm clients

Anything with an npm-shipped client fits the same crust.fn mold. Register the wrapper in init.ts; use the same client directly from a .ts script. Install the driver where the file that imports it lives — for the init.ts below, that’s ~/.config/crust/ (or your project dir for a per-project init): bun add duckdb-async @clickhouse/client.

# After init.ts has registered dql / chql:
dql "select count(*) c from read_parquet('events/*.parquet')" | (r => r.c)
chql "select user_id, count() c from clicks group by user_id" | (r => r.user_id)

Troubleshooting

ErrorLikely cause
sql: no connection (set $DATABASE_URL)$DATABASE_URL is empty. Set it inline, in your shell rc, or in init.ts via process.env.DATABASE_URL = ....
sql: this Bun build has no Bun.SQL or Bun.sql.unsafe supportYour Bun is too old (or compiled without the relevant driver). Upgrade with bun upgrade and retry.
sql: client missing .unsafe — parameterised query unsupportedA custom crust.fn("sql", …) shim is in place that doesn’t implement parameterized queries. Add params support or fall back to template-tag form.
Hangs on first queryNetwork reachability or pg_hba / firewall. Try pg_isready -d "$DATABASE_URL" (Postgres) before retrying.