- isQueue table option adds queue columns and a (status, available_at) index; BunSQLite.queue exposes add, claim, complete, fail and release_stale with atomic claims - Set busy_timeout on connections so workers in separate processes wait instead of failing with SQLITE_BUSY - Quote table and column names with backticks in generated SQL, so reserved words (to, order, group) work and unknown columns error instead of matching as string literals - Tests for queue ops, multi-process claims and reserved-word names Co-Authored-By: Claude Opus 5.5 <[email protected]>
Bun SQLite
@moduletrace/bun-sqlite
A schema-driven SQLite manager for Bun, featuring automatic schema synchronization, type-safe CRUD operations, vector embedding support (via sqlite-vec), and TypeScript type definition generation.
Table of Contents
- Features
- Prerequisites
- Installation
- Quick Start
- Configuration
- Schema Definition
- CLI Commands
- CRUD API
- Query API Reference
- Vector Table Support
- Queue Tables
- TypeScript Type Generation
- Default Fields
- Project Structure
- Testing
Features
- Schema-first design — define your database in TypeScript; the library syncs your SQLite file to match
- Automatic migrations — adds new columns, recreates tables for complex changes, drops removed tables
- Type-safe CRUD — fully generic
select,insert,update,deletefunctions with TypeScript generics - Rich query DSL — filtering, ordering, pagination, joins, grouping, full-text search, sub-query counts
- Vector table support — create and manage
sqlite-vecvirtual tables for AI/ML embeddings - TypeScript codegen — generate
.tstype definitions from your schema automatically - Zero-config defaults —
id,created_at, andupdated_atfields are added to every table automatically
Prerequisites
This works for just bun, so bun (>= 1.3) should be installed.
Installation
There are two ways to install BunSQLite:
Via the official npm registry
@moduletrace/bun-sqlite is published to a private Gitea npm registry. You must configure your package manager to resolve the @moduletrace scope from that registry before installing.
Add the following to your project's .npmrc file (create it at the root of your project if it doesn't exist):
@moduletrace:registry=https://git.tben.me/api/packages/moduletrace/npm/
After this you can run
bun add @moduletrace/bun-sqlite
Via Github
To install directly from github simply run
bun add github:moduletrace/bun-sqlite
Quick Start
1. Create the config file
Create bun-sqlite.config.ts at your project root:
import type { BunSQLiteConfig } from "@moduletrace/bun-sqlite";
const config: BunSQLiteConfig = {
db_name: "my-app.db",
db_dir: "./db", // optional: where to store the db file
db_backup_dir: ".backups", // optional: name of backups directory. Relative to the db dir.
typedef_file_path: "./db/types/db.ts", // optional: where to write generated types
};
export default config;
2. Define your schema
Create ./db/schema.ts in db_dir (or schema.js, schema.json, schema.yaml, schema.yml):
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite";
const schema: BUN_SQLITE_DatabaseSchemaType = {
dbName: "my-app",
tables: [
{
tableName: "users",
fields: [
{ fieldName: "first_name", dataType: "TEXT" },
{ fieldName: "last_name", dataType: "TEXT" },
{ fieldName: "email", dataType: "TEXT", unique: true },
],
},
],
};
export default schema;
3. Sync the schema to SQLite
bunx bun-sqlite schema
This creates the SQLite database file and creates/updates all tables to match your schema.
4. Use the CRUD API
import BunSQLite from "@moduletrace/bun-sqlite";
// Insert
await BunSQLite.insert({
table: "users",
data: [{ first_name: "Alice", email: "[email protected]" }],
});
// Select
const result = await BunSQLite.select({ table: "users" });
console.log(result.payload); // Alice's row
// Update
await BunSQLite.update({
table: "users",
targetId: 1,
data: { first_name: "Alicia" },
});
// Delete
await BunSQLite.delete({ table: "users", targetId: 1 });
Configuration
The config file is bun-sqlite.config.ts at the root of your project. bun-sqlite.config.js, .json, .yaml and .yml also work; keep only one. .ts/.js files must export default the config.
| Field | Type | Required | Description |
|---|---|---|---|
db_name |
string |
Yes | Filename for the SQLite database (e.g. "app.db") |
db_schema_file_name |
string |
No | Schema filename relative to db_dir. Defaults to schema.ts|js|json|yaml|yml in db_dir |
db_backup_dir |
string |
No | Directory for database backups, relative to db_dir |
db_dir |
string |
No | Root directory for the database file and schema. Defaults to project root |
typedef_file_path |
string |
No | Output path for generated TypeScript types, relative to project root |
max_backups |
number |
No | Maximum number of backup files to keep. Oldest are deleted automatically. Defaults to 10 |
max_exports |
number |
No | Maximum number of archives kept in {db_dir}/.exports by export. Defaults to 10 |
wal_mode |
boolean |
No | Enable SQLite WAL journal mode |
Schema Definition
Database Schema
interface BUN_SQLITE_DatabaseSchemaType {
dbName?: string;
tables: BUN_SQLITE_TableSchemaType[];
}
Table Schema
interface BUN_SQLITE_TableSchemaType {
tableName: string;
tableDescription?: string;
fields: BUN_SQLITE_FieldSchemaType[];
indexes?: BUN_SQLITE_IndexSchemaType[];
uniqueConstraints?: BUN_SQLITE_UniqueConstraintSchemaType[];
parentTableName?: string; // inherit fields from another table in the schema
tableNameOld?: string; // rename: set this to the old name to trigger ALTER TABLE RENAME
isVector?: boolean; // mark this as a sqlite-vec virtual table
vectorType?: string; // virtual table type, defaults to "vec0"
isQueue?: boolean | BUN_SQLITE_QueueOptions; // make this a job queue (see Queue Tables)
}
Field Schema
type BUN_SQLITE_FieldSchemaType = {
fieldName?: string;
dataType: "TEXT" | "INTEGER";
primaryKey?: boolean;
autoIncrement?: boolean;
notNullValue?: boolean;
unique?: boolean;
defaultValue?: string | number;
defaultValueLiteral?: string; // raw SQL literal, e.g. "CURRENT_TIMESTAMP"
foreignKey?: BUN_SQLITE_ForeignKeyType;
isVector?: boolean; // vector embedding column
vectorSize?: number; // embedding dimensions (default: 1536)
sideCar?: boolean; // sqlite-vec "+" prefix for side-car columns
updatedField?: boolean; // flag that this field definition has changed
};
Foreign Key
interface BUN_SQLITE_ForeignKeyType {
destinationTableName: string;
destinationTableColumnName: string;
cascadeDelete?: boolean;
cascadeUpdate?: boolean;
}
Index
interface BUN_SQLITE_IndexSchemaType {
indexName?: string;
indexType?: "regular" | "full_text" | "vector";
indexTableFields?: { value: string; dataType: string }[];
}
Unique Constraint
interface BUN_SQLITE_UniqueConstraintSchemaType {
constraintName?: string;
constraintTableFields?: { value: string }[];
}
CLI Commands
The package provides a bun-sqlite CLI binary.
schema — Sync database to schema
bunx bun-sqlite schema [options]
| Option | Description |
|---|---|
-v, --vector |
Drop and recreate all vector (sqlite-vec) virtual tables |
-t, --typedef |
Also generate TypeScript type definitions after syncing |
--dry-run |
Print the planned schema changes without applying them |
-y, --yes |
Apply destructive changes (dropped tables / columns) without confirmation |
Examples:
# Sync schema only
bunx bun-sqlite schema
# Sync schema and regenerate types
bunx bun-sqlite schema --typedef
# Sync schema, recreate vector tables, and regenerate types
bunx bun-sqlite schema --vector --typedef
# Review planned changes first
bunx bun-sqlite schema --dry-run
Only tables whose definition changed are rebuilt. Changes that discard data (dropping a table, or a column removed from the schema) are listed and need confirmation before they run. Non-interactive runs (CI, scripts) refuse them unless --yes is passed. The plan is exact: --dry-run runs the whole sync in a transaction and rolls it back.
typedef — Generate TypeScript types only
bunx bun-sqlite typedef
Reads the schema and writes TypeScript type definitions to the path configured in typedef_file_path.
backup — Back up the database
bunx bun-sqlite backup
Copies the current database file into db_backup_dir with a timestamped filename. After copying, the oldest backups are automatically pruned so the number of stored backups never exceeds max_backups (default: 10).
Example:
bunx bun-sqlite backup
# Backing up database ...
# DB Backup Success!
restore — Restore the database from a backup
bunx bun-sqlite restore
Presents an interactive list of available backups sorted by date (newest first). Select a backup to overwrite the current database file with it.
Example:
bunx bun-sqlite restore
# Restoring up database ...
# ? Select a backup: (Use arrow keys)
# ❯ Backup #1: Mon Mar 02 2026 14:30:00
# Backup #2: Sun Mar 01 2026 09:15:42
# DB Restore Success!
If no backups exist, the command exits with an error and a reminder to run
backupfirst.
export — Portable database + schema archive
# Write {db_name}-{timestamp}.tar.gz into {db_dir}/.exports
bunx bun-sqlite export
# Explicit path / format
bunx bun-sqlite export -o ./my-app.tar.gz
bunx bun-sqlite export -f zip
The archive holds database.sqlite (a consistent VACUUM INTO snapshot) and your schema file as schema.<ext>. Archives in .exports are trimmed to max_exports. .zip needs the zip binary.
import — Database file, SQL script, or export archive
# Interactive picker (from {db_dir}/.exports)
bunx bun-sqlite import
# Full export (database + schema)
bunx bun-sqlite import ./my-app.tar.gz
# Archive: database only / schema only
bunx bun-sqlite import ./my-app.tar.gz --sql-only
bunx bun-sqlite import ./my-app.tar.gz --schema-only
# A SQLite database file, or an SQL script (run into a fresh database)
bunx bun-sqlite import ./other.db
bunx bun-sqlite import ./dump.sql
The current database is backed up to db_backup_dir before it is replaced. A .ts/.js schema from an archive runs as code on the next load, so import asks before writing it (pass --yes to skip the prompt).
CRUD API
Import the default export:
import BunSQLite from "@moduletrace/bun-sqlite";
All methods return an APIResponseObject<T>:
{
success: boolean;
payload?: T[]; // array of rows (select; also insert/update)
singleRes?: T; // first row (select; also insert/update)
count?: number; // total count (when count: true)
postInsertReturn?: {
affectedRows?: number;
insertId?: number;
};
error?: string;
msg?: string;
debug?: any;
}
Select
BunSQLite.select<T>({ table, query?, count?, targetId? })
| Parameter | Type | Description |
|---|---|---|
table |
string |
Table name |
query |
ServerQueryParam<T> |
Query/filter options (see Query API) |
count |
boolean |
Return row count instead of rows |
targetId |
string | number |
Shorthand to filter by id |
Examples:
// Get all users
const res = await BunSQLite.select({ table: "users" });
// Get by ID
const res = await BunSQLite.select({ table: "users", targetId: 42 });
// Filter with LIKE
const res = await BunSQLite.select<UserType>({
table: "users",
query: {
query: {
first_name: { value: "Ali", equality: "LIKE" },
},
},
});
// Count rows
const res = await BunSQLite.select({ table: "users", count: true });
console.log(res.count);
// Pagination
const res = await BunSQLite.select({
table: "users",
query: { limit: 10, page: 2 },
});
Insert
BunSQLite.insert<T>({ table, data, update_on_duplicate?, returning? });
| Parameter | Type | Description |
|---|---|---|
table |
string |
Table name |
data |
T[] |
Array of row objects to insert |
update_on_duplicate |
boolean |
Upsert via ON CONFLICT ... DO UPDATE |
returning |
boolean |
Return the inserted record(s). Defaults to true |
created_at and updated_at are set automatically to Date.now().
On success the inserted record(s) come back in singleRes (first row) and
payload (all rows, for multi-row inserts). postInsertReturn is still
populated for backwards compatibility.
Example:
const res = await BunSQLite.insert({
table: "users",
data: [
{ first_name: "Alice", last_name: "Smith", email: "[email protected]" },
{ first_name: "Bob", last_name: "Jones", email: "[email protected]" },
],
});
console.log(res.singleRes); // first inserted record (with its id)
console.log(res.payload); // every inserted record
console.log(res.postInsertReturn?.insertId); // last inserted row ID
Pass returning: false to skip RETURNING (e.g. when inserting very large
BLOB/vector values); the response then only contains postInsertReturn.
Update
BunSQLite.update<T>({ table, data, query?, targetId?, returning? })
| Parameter | Type | Description |
|---|---|---|
table |
string |
Table name |
data |
Partial<T> |
Fields to update |
query |
ServerQueryParam<T> |
WHERE clause conditions |
targetId |
string | number |
Shorthand to filter by id |
returning |
boolean |
Return the updated record(s). Defaults to true |
A WHERE clause is required. If no condition matches, success is false.
updated_at is set automatically to Date.now().
On success the updated record(s) come back in singleRes (first row) and
payload. Vector tables fall back to postInsertReturn automatically, because
SQLite does not support UPDATE ... RETURNING on virtual tables.
Examples:
// Update by ID
const res = await BunSQLite.update({
table: "users",
targetId: 1,
data: { first_name: "Alicia" },
});
console.log(res.singleRes); // the updated record
// Update with custom query
await BunSQLite.update({
table: "users",
data: { last_name: "Doe" },
query: {
query: {
email: { value: "[email protected]", equality: "EQUAL" },
},
},
});
Delete
BunSQLite.delete<T>({ table, query?, targetId? })
| Parameter | Type | Description |
|---|---|---|
table |
string |
Table name |
query |
ServerQueryParam<T> |
WHERE clause conditions |
targetId |
string | number |
Shorthand to filter by id |
A WHERE clause is required. If no condition is provided, success is false.
Examples:
// Delete by ID
await BunSQLite.delete({ table: "users", targetId: 1 });
// Delete with condition
await BunSQLite.delete({
table: "users",
query: {
query: {
first_name: { value: "Ben", equality: "LIKE" },
},
},
});
Raw SQL
BunSQLite.sql<T>({ sql, values? })
| Parameter | Type | Description |
|---|---|---|
sql |
string |
Raw SQL statement |
values |
(string | number)[] |
Parameterized values |
SELECT statements return rows; all other statements return postInsertReturn.
Examples:
// SELECT
const res = await BunSQLite.sql<UserType>({ sql: "SELECT * FROM users" });
console.log(res.payload);
// INSERT with params
await BunSQLite.sql({
sql: "INSERT INTO users (first_name, email) VALUES (?, ?)",
values: ["Charlie", "[email protected]"],
});
Query API Reference
The query parameter on select, update, and delete accepts a ServerQueryParam<T> object:
type ServerQueryParam<T> = {
query?: { [key in keyof T]: ServerQueryObject };
selectFields?: (keyof T | { fieldName: keyof T; alias?: string })[];
omitFields?: (keyof T)[];
limit?: number;
page?: number;
offset?: number;
order?:
| { field: keyof T; strategy: "ASC" | "DESC" }
| { field: keyof T; strategy: "ASC" | "DESC" }[];
searchOperator?: "AND" | "OR"; // how multiple query fields are joined (default: AND)
join?: ServerQueryParamsJoin[];
group?:
| keyof T
| { field: keyof T; table?: string }
| (keyof T | { field: keyof T; table?: string })[];
countSubQueries?: ServerQueryParamsCount[];
fullTextSearch?: {
fields: (keyof T)[];
searchTerm: string;
scoreAlias: string;
};
};
Equality Operators
Set equality on any query field to control the comparison:
| Equality | SQL Equivalent |
|---|---|
EQUAL (default) |
= |
NOT EQUAL |
!= |
LIKE |
LIKE '%value%' |
LIKE_RAW |
LIKE 'value' (no auto-wrapping) |
LIKE_LOWER |
LOWER(field) LIKE '%value%' |
NOT LIKE |
NOT LIKE '%value%' |
GREATER THAN |
> |
GREATER THAN OR EQUAL |
>= |
LESS THAN |
< |
LESS THAN OR EQUAL |
<= |
IN |
IN (val1, val2, ...) — pass array as value |
NOT IN |
NOT IN (...) |
BETWEEN |
BETWEEN val1 AND val2 — pass [val1, val2] as value |
IS NULL |
IS NULL |
IS NOT NULL |
IS NOT NULL |
MATCH |
sqlite-vec vector nearest-neighbor search |
Example:
// Find users with email NOT NULL, ordered by created_at DESC, limit 20
const res = await BunSQLite.select<UserType>({
table: "users",
query: {
query: {
email: { equality: "IS NOT NULL" },
},
order: { field: "created_at", strategy: "DESC" },
limit: 20,
},
});
JOIN
const res = await BunSQLite.select({
table: "posts",
query: {
join: [
{
joinType: "LEFT JOIN",
tableName: "users",
match: { source: "user_id", target: "id" },
selectFields: ["first_name", "email"],
},
],
},
});
Vector Table Support
@moduletrace/bun-sqlite integrates with sqlite-vec for storing and querying vector embeddings.
Define a vector table in the schema
{
tableName: "documents",
isVector: true,
vectorType: "vec0", // defaults to "vec0"
fields: [
{
fieldName: "embedding",
dataType: "TEXT",
isVector: true,
vectorSize: 1536, // embedding dimensions
},
{
fieldName: "title",
dataType: "TEXT",
sideCar: true, // stored as a side-car column (+title) for efficiency
},
{
fieldName: "body",
dataType: "TEXT",
sideCar: true,
},
],
}
Side-car columns (
sideCar: true) use sqlite-vec's+columnsyntax. They are stored separately from the vector index, keeping the index lean and fast while still being queryable alongside vector results.
Sync vector tables
# Initial sync
bunx bun-sqlite schema
# Recreate vector tables (e.g. after changing vectorSize)
bunx bun-sqlite schema --vector
Query vectors
const res = await BunSQLite.select({
table: "documents",
query: {
query: {
embedding: {
equality: "MATCH",
value: "<serialized-vector>",
vector: true,
vectorFunction: "vec_f32",
},
},
limit: 5,
},
});
Queue Tables
Set isQueue on a table to use it as a job queue. Your fields are the job payload; the schema sync adds the queue columns and an index on (status, available_at).
{
tableName: "email_jobs",
isQueue: {
maxAttempts: 3, // claims before a failing job is marked "failed" (default 3)
lockTimeout: 300_000, // ms a job can stay "processing" before it is released (default 5 min)
backoff: 1000, // base retry delay in ms, doubled each attempt (default 1000)
deleteOnComplete: false, // delete completed jobs instead of keeping them (default false)
},
fields: [
{ fieldName: "recipient", dataType: "TEXT" },
{ fieldName: "subject", dataType: "TEXT" },
],
}
isQueue: true uses the defaults. A table can't be both a queue and a vector table.
| Field | Type | Description |
|---|---|---|
status |
TEXT NOT NULL |
pending, processing, completed or failed |
attempts |
INTEGER NOT NULL DEFAULT 0 |
Times the job has been claimed |
available_at |
INTEGER NOT NULL DEFAULT 0 |
Unix timestamp (ms) from which the job can be claimed |
locked_at |
INTEGER |
Unix timestamp (ms) when the job was claimed |
locked_by |
TEXT |
Worker that claimed the job |
last_error |
TEXT |
Error from the last failed attempt |
Working the queue
// Enqueue (optionally delayed, in ms)
await BunSQLite.queue.add({
table: "email_jobs",
data: [{ recipient: "[email protected]", subject: "Hi" }],
delay: 0,
});
// Worker loop
const { payload: jobs } = await BunSQLite.queue.claim({
table: "email_jobs",
worker: "worker-1",
limit: 10,
});
for (const job of jobs || []) {
try {
await sendEmail(job);
await BunSQLite.queue.complete({ table: "email_jobs", id: job.id });
} catch (error: any) {
await BunSQLite.queue.fail({
table: "email_jobs",
id: job.id,
error: error.message,
});
}
}
claimsets jobs toprocessingin a singleUPDATE ... RETURNINGstatement. SQLite only allows one writer at a time, so two workers never get the same job, even across processes.failputs the job back topendingwith an exponential backoff, or sets it tofailedonce it has usedmaxAttempts.- Jobs stuck in
processinglonger thanlockTimeout(for example, the worker crashed) are released byclaimbefore it picks jobs. You can also callBunSQLite.queue.release_stale({ table })directly. completeandfailonly touch jobs that areprocessing.
TypeScript Type Generation
Run the typedef command (or pass --typedef to schema) to generate a .ts file containing:
- A
constarray of all table names (BunSQLiteTables) - A
typefor each table (namedBUN_SQLITE_<DB_NAME>_<TABLE_NAME>) - A union type
BUN_SQLITE_<DB_NAME>_ALL_TYPEDEFS
Example output (db/types/db.ts):
export const BunSQLiteTables = ["users"] as const;
export type BUN_SQLITE_MY_APP_USERS = {
/** The unique identifier of the record. */
id?: number;
/** The time when the record was created. (Unix Timestamp) */
created_at?: number;
/** The time when the record was updated. (Unix Timestamp) */
updated_at?: number;
first_name?: string;
last_name?: string;
email?: string;
};
export type BUN_SQLITE_MY_APP_ALL_TYPEDEFS = BUN_SQLITE_MY_APP_USERS;
Use the generated types with the CRUD API for full type safety:
import BunSQLite from "@moduletrace/bun-sqlite";
import { BUN_SQLITE_MY_APP_USERS, BunSQLiteTables } from "./db/types/db";
const res = await BunSQLite.select<BUN_SQLITE_MY_APP_USERS>({
table: "users" as (typeof BunSQLiteTables)[number],
});
Default Fields
Every table automatically receives the following fields — you do not need to declare them in your schema:
| Field | Type | Description |
|---|---|---|
id |
INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL |
Unique row identifier |
created_at |
INTEGER |
Unix timestamp set on insert |
updated_at |
INTEGER |
Unix timestamp updated on every update |
Project Structure
bun-sqlite/
├── src/
│ ├── index.ts # Main export (BunSQLite object)
│ ├── commands/
│ │ ├── index.ts # CLI entry point
│ │ ├── schema.ts # `schema` command
│ │ ├── typedef.ts # `typedef` command
│ │ ├── backup.ts # `backup` command
│ │ ├── restore.ts # `restore` command
│ │ ├── export.ts # `export` command
│ │ └── import.ts # `import` command
│ ├── functions/
│ │ ├── init.ts # Loads config, schema and client into globals
│ │ └── grab-sqlite-client.ts # Database client (bun:sqlite + sqlite-vec)
│ ├── lib/
│ │ ├── db-handler.ts # Runs every CRUD query
│ │ └── schema/ # Schema synchronization (one step per file, + tests)
│ ├── lib/sqlite/
│ │ ├── db-select.ts # Select implementation
│ │ ├── db-insert.ts # Insert implementation
│ │ ├── db-update.ts # Update implementation
│ │ ├── db-delete.ts # Delete implementation
│ │ ├── db-sql.ts # Raw SQL implementation
│ │ ├── db-generate-type-defs.ts # Type def generator
│ │ └── schema-to-typedef.ts # Schema-to-TypeScript converter
│ ├── types/
│ │ └── index.ts # All TypeScript types and interfaces
│ └── utils/
│ ├── sql-generator.ts # SELECT query builder
│ ├── sql-insert-generator.ts # INSERT query builder
│ ├── sql-gen-operator-gen.ts # Equality operator mapper
│ ├── sql-equality-parser.ts # Equality string parser
│ ├── append-default-fields-to-db-schema.ts
│ ├── resolve-and-load-data-file.ts # Load .ts/.js/.json/.yaml config and schema files
│ ├── export-archive.ts # Read/write export archives
│ ├── grab-db-dir.ts # Resolve db/backup/export directory paths
│ ├── grab-db-backup-file-name.ts # Generate timestamped backup filename
│ ├── grab-sorted-backups.ts # List backups sorted newest-first
│ ├── grab-backup-data.ts # Parse metadata from a backup filename
│ └── trim-backups.ts # Prune oldest backups over max_backups
└── test/
└── test-01/ # Example project using the library
├── bun-sqlite.config.ts
├── db/
│ ├── bun-sqlite-schema.ts
│ └── types/bun-sqlite.ts # Generated types
└── src/
├── insert.ts
├── select.ts
├── delete.ts
└── sql.ts
Testing
bun run test
Runs the unit tests (tests/) and the schema sync tests (src/lib/schema/*.test.ts) against in-memory SQLite databases. No setup needed.
License
MIT