Skip to content

JavaScript and TypeScript ORM integration

The JavaScript and TypeScript integrations all start with the same move: load libhonker_ext on the underlying SQLite handle, then wrap the honker_* SQL calls in a small helper that matches your app’s style.

The same recipe works for bun:sqlite — both expose db.loadExtension(path) identically.

const Database = require('better-sqlite3');
const { extensionPath } = require('@russellthehippo/honker-node/extension');
// Bun: import { Database } from 'bun:sqlite';
// import { extensionPath } from '@russellthehippo/honker-bun';
const db = new Database('app.db');
db.loadExtension(extensionPath());
db.prepare('SELECT honker_bootstrap()').run();

better-sqlite3 binds every JavaScript number as SQLite REAL, whole ones included, so job ids and options like priority arrive as REAL. The honker_* functions accept that and coerce whole values to integers, so plain numbers work — no CAST or BigInt needed. A number with a fractional part is still an error rather than being rounded.

yourapp/honker-ext.ts
import type Database from 'better-sqlite3';
// import type { Database } from 'bun:sqlite';
export class Queue<T> {
constructor(
private readonly db: Database.Database,
public readonly name: string,
private readonly maxAttempts: number = 3,
) {}
enqueue(
payload: T,
opts: { delay?: number; priority?: number } = {},
): number {
const row = this.db.prepare(
"SELECT honker_enqueue(?, ?, NULL, ?, ?, ?, NULL) AS id"
).get(
this.name,
JSON.stringify(payload),
opts.delay ?? null,
opts.priority ?? 0,
this.maxAttempts,
) as { id: number };
return row.id;
}
}
export function notify(
db: Database.Database,
channel: string,
payload: unknown = null,
): void {
db.prepare('SELECT notify(?, ?)').run(
channel,
payload === null ? null : JSON.stringify(payload),
);
}
interface EmailJob { to: string; subject: string }
const emails = new Queue<EmailJob>(db, 'emails');
const createOrder = db.transaction((userId: number, email: EmailJob) => {
db.prepare('INSERT INTO orders (user_id) VALUES (?)').run(userId);
emails.enqueue(email);
});
createOrder(42, { to: 'alice@example.com', subject: 'Hi' });

Drizzle’s SQLite driver sits on top of better-sqlite3, so you reach through to the underlying connection to load the extension.

import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { extensionPath } from '@russellthehippo/honker-node/extension';
const sqlite = new Database('app.db');
sqlite.loadExtension(extensionPath());
sqlite.prepare('SELECT honker_bootstrap()').run();
export const db = drizzle(sqlite);
yourapp/honker-ext.ts
import { sql } from 'drizzle-orm';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
export class Queue<T> {
constructor(
public readonly name: string,
private readonly maxAttempts: number = 3,
) {}
enqueue(
db: BetterSQLite3Database,
payload: T,
opts: { delay?: number; priority?: number } = {},
): number {
const row = db.get<{ id: number }>(sql`
SELECT honker_enqueue(
${this.name},
${JSON.stringify(payload)},
NULL,
${opts.delay ?? null},
${opts.priority ?? 0},
${this.maxAttempts},
NULL
) AS id
`);
return row!.id;
}
}
export function notify(
db: BetterSQLite3Database,
channel: string,
payload: unknown = null,
): void {
db.run(sql`
SELECT notify(${channel}, ${payload === null ? null : JSON.stringify(payload)})
`);
}
import { Queue, notify } from './honker-ext';
interface EmailJob { to: string; subject: string }
const emails = new Queue<EmailJob>('emails');
db.transaction((tx) => {
tx.insert(orders).values({ userId: 42 }).run();
emails.enqueue(tx, { to: 'alice@example.com', subject: 'Hi' });
notify(tx, 'orders', { id: 42 });
});

Kysely wraps better-sqlite3 the same way Drizzle does.

import Database from 'better-sqlite3';
import { Kysely, SqliteDialect } from 'kysely';
import { extensionPath } from '@russellthehippo/honker-node/extension';
const sqlite = new Database('app.db');
sqlite.loadExtension(extensionPath());
sqlite.prepare('SELECT honker_bootstrap()').run();
export const db = new Kysely<DB>({ dialect: new SqliteDialect({ database: sqlite }) });
yourapp/honker-ext.ts
import { sql, Kysely, Transaction } from 'kysely';
import type { DB } from './schema';
type Executor = Kysely<DB> | Transaction<DB>;
export class Queue<T> {
constructor(
public readonly name: string,
private readonly maxAttempts: number = 3,
) {}
async enqueue(
db: Executor,
payload: T,
opts: { delay?: number; priority?: number } = {},
): Promise<number> {
const { rows } = await sql<{ id: number }>`
SELECT honker_enqueue(
${this.name},
${JSON.stringify(payload)},
NULL,
${opts.delay ?? null},
${opts.priority ?? 0},
${this.maxAttempts},
NULL
) AS id
`.execute(db);
return rows[0].id;
}
}
const emails = new Queue<EmailJob>('emails');
await db.transaction().execute(async (tx) => {
await tx.insertInto('orders').values({ user_id: 42 }).execute();
await emails.enqueue(tx, { to: 'alice@example.com', subject: 'Hi' });
});

Prisma still can’t load SQLite extensions, though the reason changed with Prisma 7.

Prisma 7 requires a driver adapter — a url in the datasource block is no longer even accepted — and the official SQLite adapter, @prisma/adapter-better-sqlite3, drives the same better-sqlite3 that loads extensions fine on its own. So the old explanation (“Prisma’s SQLite build omits SQLITE_ENABLE_LOAD_EXTENSION”) no longer applies.

What blocks it now is narrower: the adapter takes better-sqlite3’s Options but never hands you the Database, and it exposes no extension hook. Verified against @prisma/adapter-better-sqlite3 7.9.1 — SELECT load_extension(?) through $queryRawUnsafe is rejected, and honker_bootstrap() comes back no such function. Feature request #20271 tracks the ask.

Driver adapters are a public interface, so a custom adapter wrapping a better-sqlite3 connection you opened and loaded yourself would work. That is a real amount of surface to implement and maintain, so the two options below are usually the better trade.

Two ways forward:

  1. Open a separate Honker connection on the same file. Prisma owns its connection; you open a second one via better-sqlite3 for the honker_* calls. Simple, but you lose atomicity between the business write and the enqueue — at that point Honker is alongside Prisma, not inside it.
  2. Move the affected writes off Prisma. Wrap the writes that need the atomic enqueue in a better-sqlite3 or Drizzle transaction (see those sections above). Keep Prisma for everything else.

No great answer until Prisma ships extension support. If/when they do, this section will shorten.