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.
better-sqlite3 (Node / Bun)
Section titled “better-sqlite3 (Node / Bun)”The same recipe works for bun:sqlite — both expose db.loadExtension(path) identically.
Wiring
Section titled “Wiring”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.
Wrapper
Section titled “Wrapper”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 ORM
Section titled “Drizzle ORM”Drizzle’s SQLite driver sits on top of better-sqlite3, so you reach through to the underlying connection to load the extension.
Wiring
Section titled “Wiring”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);Wrapper
Section titled “Wrapper”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
Section titled “Kysely”Kysely wraps better-sqlite3 the same way Drizzle does.
Wiring
Section titled “Wiring”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 }) });Wrapper
Section titled “Wrapper”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
Section titled “Prisma”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:
- Open a separate Honker connection on the same file. Prisma owns its connection; you open a second one via
better-sqlite3for thehonker_*calls. Simple, but you lose atomicity between the business write and the enqueue — at that point Honker is alongside Prisma, not inside it. - Move the affected writes off Prisma. Wrap the writes that need the atomic enqueue in a
better-sqlite3or 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.