Back to catalog

@bun-and-butter/sqlite

A Bun-first SQLite helper for creating and configuring Bun SQL connections with sensible defaults, configurable PRAGMAs, typed options, lifecycle hooks, and clear configuration errors.

Tags
v1.0.0
Install

Install from Git

Bun & Butter packages are installed directly from the GitHub repository over SSH. This is the recommended install path for the current version. See the Bun docs on Git dependencies for the exact `bun add` behavior.

					bun add [email protected]:bun-and-butter/sqlite.git
				
Documentation

README.md

A Bun-first SQLite helper for creating and configuring Bun SQL connections with sensible defaults, configurable PRAGMAs, typed options, lifecycle hooks, and clear configuration errors.

@bun-and-butter/sqlite is built for the common case where you want to use SQLite through Bun SQL, but do not want to hand-roll the connection setup every time. It gives you a small wrapper around Bun SQL that applies production-friendly defaults, exposes the most important SQLite tuning options through a typed API, and keeps configuration errors separate from normal Bun SQL runtime errors.

Quick Start

import { buildSQLite } from "@bun-and-butter/sqlite";

interface MessageRow {
	message: string;
}

const sqlite = await buildSQLite();

try {
	await sqlite`CREATE TABLE messages (message TEXT NOT NULL);`;
	await sqlite`INSERT INTO messages (message) VALUES (${"Hello from SQLite"});`;

	const rows = await sqlite<
		MessageRow[]
	>`SELECT message FROM messages ORDER BY rowid;`;

	console.log(rows[0]?.message);
} finally {
	await sqlite.close();
}

Why Use It

Using Bun SQL directly is already pleasant, but SQLite connections often need a bit of repeated setup around them:

  • pick a sensible synchronous mode
  • decide when PRAGMA optimize should run
  • enable foreign keys
  • configure cache size, busy timeout, WAL checkpointing, and temp storage
  • parse sqlite:// paths and optional ?mode=...
  • run connection lifecycle hooks
  • distinguish invalid configuration from normal SQL runtime failures

buildSQLite(...) keeps that setup small and consistent while still returning a normal Bun SQL client for queries.

What It Handles

The package currently focuses on the connection layer:

  • creates a Bun SQL client configured for the SQLite adapter
  • accepts plain file paths and Bun-style sqlite:// paths
  • supports ro, rw, and rwc modes
  • applies sensible defaults for long-running processes
  • lets you override important SQLite PRAGMAs with typed options
  • supports onAfterConnect, onBeforeClose, and onAfterClose hooks
  • provides deleteSQLiteFiles(...) for removing a database together with its -wal and -shm files

Error Handling

The package throws SQLiteError only for configuration problems handled by the package itself, for example:

  • an empty path
  • an invalid mode
  • other invalid connection setup passed into buildSQLite(...)

Everything else is left as a normal Bun SQL or SQLite runtime error. That means query failures, connection failures, and close-time failures are not wrapped or translated.

Defaults

When you call buildSQLite() without overrides, the package uses defaults aimed at long-running processes such as servers, workers, and other application runtimes.

That includes:

  • an in-memory database when no path is provided
  • mode=rwc
  • synchronous = normal
  • foreign_keys = on
  • busy_timeout = 5000
  • cache_size = -65536
  • journal_mode = wal for writable connections
  • periodic PRAGMA optimize

For short-lived scripts or one-off jobs, you can override those defaults with Optimize.OnExit and any other options you need.

Examples

The repository currently includes four examples:

Design Notes

  • the returned connection is a normal Bun SQL client
  • close() is wrapped so optimize behavior and lifecycle hooks run in the right order
  • repeated close() calls are guarded so cleanup does not run multiple times
  • configuration is resolved once up front so the runtime code can work with a complete option object

For most applications:

  1. start with buildSQLite() or buildSQLite({ path })
  2. keep the defaults unless you have a concrete reason to change them
  3. use Optimize.OnExit for short-lived jobs
  4. use the default optimize behavior for long-running services
  5. treat SQLiteError as a configuration problem and all other errors as Bun SQL runtime failures
Usage Examples

Examples

examples/demo.ts
import { buildSQLite, SQLiteError } from "../src/sqlite";

// Start with `buildSQLite()` when a small SQLite connection with good defaults
// is all you need.
//
// Without any options, the package opens an in-memory database and applies the
// default settings for a long-running process. From there on, the returned value
// is a normal Bun SQL client and can be used with `sql\`\`` like any other Bun
// SQL connection.

interface DemoRow {
	message: string;
}

try {
	// No path is passed here, so `buildSQLite()` falls back to `:memory:`.
	const sqlite = await buildSQLite();

	try {
		await sqlite`CREATE TABLE messages (message TEXT NOT NULL);`;
		await sqlite`INSERT INTO messages (message) VALUES (${"Hello from SQLite"});`;

		const rows = await sqlite<
			DemoRow[]
		>`SELECT message FROM messages ORDER BY rowid;`;

		console.log(rows[0]?.message);
	} finally {
		await sqlite.close();
	}
} catch (error) {
	// `SQLiteError` is reserved for invalid package configuration.
	if (error instanceof SQLiteError) {
		console.error("Invalid SQLite configuration:", error.message);
	} else {
		// Query failures and runtime SQLite failures come from Bun unchanged.
		console.error("Bun SQL runtime error:", error);
	}
}
examples/full_demo.ts
import {
	AutoVacuum,
	buildSQLite,
	Mode,
	Optimize,
	SQLiteError,
	Synchronous,
} from "../src/sqlite";

// Use this version when you want to see the full surface area of the package in
// one place.
//
// The connection below is still created against `:memory:` so the snippet stays
// self-contained, but it also shows how the package maps typed options to SQLite
// PRAGMAs, how lifecycle hooks fit into the connection flow, and how normal Bun
// SQL queries continue to work on the returned client.

interface DemoTaskRow {
	id: number;
	title: string;
	done: number;
}

try {
	// Each option here overrides one of the defaults and makes the resulting
	// SQLite setup explicit.
	const sqlite = await buildSQLite({
		path: ":memory:",
		mode: Mode.ReadWriteCreate,
		autoVacuum: AutoVacuum.None,
		optimize: Optimize.Periodically(60_000),
		synchronous: Synchronous.Normal,
		busyTimeout: 5_000,
		cacheSize: -65_536,
		foreignKeys: true,
		tempStoreMemory: true,
		walAutoCheckpoint: 1_000,

		// Hooks make it easy to attach setup and cleanup logic directly to the
		// connection lifecycle.
		onAfterConnect: async (db) => {
			const [journalMode] = await db<
				{ journal_mode: string }[]
			>`PRAGMA journal_mode;`;

			console.log(
				"Connected to SQLite with journal mode:",
				journalMode?.journal_mode,
			);
		},
		onBeforeClose: async () => {
			console.log("Closing SQLite connection...");
		},
		onAfterClose: async () => {
			console.log("SQLite connection closed.");
		},
	});

	try {
		// Once the connection is created, the returned client is used exactly like
		// normal Bun SQL.
		await sqlite`
			CREATE TABLE tasks (
				id INTEGER PRIMARY KEY,
				title TEXT NOT NULL,
				done INTEGER NOT NULL DEFAULT 0
			);
		`;

		await sqlite`
			INSERT INTO tasks (title, done)
			VALUES (${"Write docs"}, ${1}),
			       (${"Ship release"}, ${0}),
			       (${"Refill coffee"}, ${0});
		`;

		const tasks = await sqlite<
			DemoTaskRow[]
		>`SELECT id, title, done FROM tasks ORDER BY id;`;

		console.log("All tasks:", tasks);

		// Querying the PRAGMAs back is a simple way to inspect how the connection
		// was configured at runtime.
		const [busyTimeout] = await sqlite<
			{ timeout: number }[]
		>`PRAGMA busy_timeout;`;
		const [cacheSize] = await sqlite<
			{ cache_size: number }[]
		>`PRAGMA cache_size;`;
		const [foreignKeys] = await sqlite<
			{ foreign_keys: number }[]
		>`PRAGMA foreign_keys;`;

		console.log("busy_timeout:", busyTimeout?.timeout);
		console.log("cache_size:", cacheSize?.cache_size);
		console.log("foreign_keys:", foreignKeys?.foreign_keys);
	} finally {
		// `close()` runs the configured cleanup logic and then closes the Bun SQL
		// connection.
		await sqlite.close();
	}
} catch (error) {
	// Configuration problems are reported as `SQLiteError`.
	if (error instanceof SQLiteError) {
		console.error("Invalid SQLite configuration:", error.message);
	} else {
		// Runtime failures stay as normal Bun SQL / SQLite errors.
		console.error("Bun SQL runtime error:", error);
	}
}
examples/long_living_process.ts
import { buildSQLite, SQLiteError } from "../src/sqlite";

// Use this shape for processes that keep a SQLite connection open for a longer
// time, for example a web service or a worker process.
//
// The important point here is that the package defaults already target this kind
// of process. The example therefore keeps the options small and relies on those
// defaults instead of repeating them explicitly.

interface VisitorRow {
	route: string;
	hits: number;
}

const sqlite = await buildSQLite({
	path: ":memory:",

	onAfterConnect: async () => {
		console.log("SQLite service connection is ready.");
	},
	onBeforeClose: async () => {
		console.log("Service shutdown: draining SQLite connection.");
	},
	onAfterClose: async () => {
		console.log("Service shutdown complete.");
	},
});

try {
	// Long-lived processes typically create or verify their schema during startup.
	await sqlite`
		CREATE TABLE route_hits (
			route TEXT PRIMARY KEY,
			hits INTEGER NOT NULL DEFAULT 0
		);
	`;

	// This stands in for a route handler or service method.
	// Each call records one request for the given route.
	async function recordRequest(route: string): Promise<void> {
		await sqlite`
			INSERT INTO route_hits (route, hits)
			VALUES (${route}, 1)
			ON CONFLICT(route)
			DO UPDATE SET hits = route_hits.hits + 1;
		`;
	}

	// This helper reads the current counters, for example to expose them through
	// an API endpoint or an admin screen.
	async function listRouteHits(): Promise<VisitorRow[]> {
		return sqlite<
			VisitorRow[]
		>`SELECT route, hits FROM route_hits ORDER BY route;`;
	}

	await recordRequest("/health");
	await recordRequest("/users");
	await recordRequest("/users");

	console.log("Route counters:", await listRouteHits());

	// A real service would usually keep running after this point and reuse the
	// same connection for future requests.
} catch (error) {
	if (error instanceof SQLiteError) {
		console.error("Invalid SQLite configuration:", error.message);
	} else {
		console.error("Bun SQL runtime error:", error);
	}
} finally {
	await sqlite.close();
}
examples/short_living_process.ts
import { buildSQLite, Optimize, SQLiteError, Synchronous } from "../src/sqlite";

// Use this configuration for work that starts, does one focused job, and exits
// again shortly afterwards.
//
// Typical examples are:
// - scripts
// - import jobs
// - migration helpers
// - one-off parsers
//
// In that kind of process, `Optimize.OnExit` is usually a better fit than a
// periodic optimize timer because the connection is not expected to stay alive
// for long.

interface ImportedUserRow {
	id: number;
	email: string;
}

try {
	const sqlite = await buildSQLite({
		path: ":memory:",

		// Optimize once at shutdown instead of on an interval.
		optimize: Optimize.OnExit,

		// `full` is a reasonable choice for small jobs where durability matters more
		// than raw throughput.
		synchronous: Synchronous.Full,

		onAfterConnect: async () => {
			console.log("Import job started.");
		},
		onAfterClose: async () => {
			console.log("Import job finished.");
		},
	});

	try {
		// The rest of the code is ordinary Bun SQL.
		await sqlite`
			CREATE TABLE imported_users (
				id INTEGER PRIMARY KEY,
				email TEXT NOT NULL UNIQUE
			);
		`;

		for (const email of ["[email protected]", "[email protected]"]) {
			await sqlite`
				INSERT INTO imported_users (email)
				VALUES (${email});
			`;
		}

		const users = await sqlite<
			ImportedUserRow[]
		>`SELECT id, email FROM imported_users ORDER BY id;`;

		console.log("Imported users:", users);
	} finally {
		// Closing the connection triggers the `Optimize.OnExit` behavior.
		await sqlite.close();
	}
} catch (error) {
	if (error instanceof SQLiteError) {
		console.error("Invalid SQLite configuration:", error.message);
	} else {
		console.error("Bun SQL runtime error:", error);
	}
}