Skip to content

drivers

GitHub Actions edited this page Sep 18, 2026 · 8 revisions

Drivers

Cross-runtime connection drivers and pooling for PostgreSQL, MariaDB/MySQL, SQLite, MongoDB, Redis, and Memcached — plus edge/serverless HTTP dialects (Neon, Turso, Cloudflare D1) — on Deno, Bun, and Node.js.

JSR JSR Score Deno Bun Node.js

Overview

@tundralibs/drivers provides the wire-level connection layer for higher-level TundraLibs packages (Cacher, future NORM) and for application code that needs direct access to a backend.

Each driver speaks its target service's native protocol over plain TCP / Unix sockets, with no runtime-specific dependencies. Connection lifecycle, pooling, SSL loading, and statistics are handled once in BaseEngine; concrete drivers just supply the protocol-level hooks (create connection, validate, ping, run commands).

The result is a uniform API across services — you connect, run operations, disconnect — with the same shape whether you're talking to Memcached, a SQL database, or anything else.

For socket-less edge/serverless runtimes (Vercel Edge and the browser), NeonHttpEngine speaks Postgres over HTTPS fetch, and TursoEngine and D1Engine speak SQLite (Turso / libSQL, and Cloudflare D1) over HTTPS fetch — instead of a TCP socket or a native binding, same engine surface, no sockets. Two edge runtimes aren't actually socket-less: Deno Deploy runs the real Deno runtime, so every TCP engine (PostgresEngine, MariaEngine, RedisEngine, MemcachedEngine) connects there natively via Deno.connect; Cloudflare Workers connects PostgresEngine, RedisEngine and MemcachedEngine through @tundralibs/compat's net module running on cloudflare:sockets — no nodejs_compat flag needed. MariaEngine also connects on Workers, but by a different path: it wraps the third-party mariadb driver directly (not compat/net), so it needs Wrangler's nodejs_compat flag to shim node:net underneath it. See the compatibility matrix for how every engine compares on transport, edge-safety, and capabilities.

Modules

Module Description Documentation
BaseEngine Abstract base — connection lifecycle, inline pool, SSL, events Docs
SQLEngine Abstract SQL/document base — transactions, query execution, named-param rewriting Docs
Error Handling Standardized error codes and handling patterns for all engines Docs
Memcached Memcached driver (text protocol over TCP) Docs
Redis Redis driver (RESP3, from scratch) Docs
Postgres Postgres driver (wire protocol from scratch, SCRAM-SHA-256). Status: 1.0.0-rc. Docs
MariaDB MariaDB / MySQL driver (wraps npm:mariadb) Docs
SQLite SQLite driver (runtime-branched: bun:sqlite / jsr:@db/sqlite / node:sqlite) Docs
MongoDB MongoDB driver (wraps npm:mongodb) Docs
Neon (HTTP) Postgres-over-HTTP edge driver — fetch-only, no sockets. Edge/serverless-safe. Docs
Turso (HTTP) SQLite-over-HTTP edge driver — fetch-only, no native binding. Edge/serverless-safe. Docs
Cloudflare D1 (HTTP) SQLite-over-HTTP (D1 REST) edge driver — fetch-only, no native binding. Edge/serverless-safe. Docs
Compatibility matrix Every engine's kind, dialect, transport, edge-safety, and declared capabilities Docs

Installation

Deno:

deno add jsr:@tundralibs/drivers

Bun:

bunx jsr add @tundralibs/drivers

Node.js:

npx jsr add @tundralibs/drivers

Import

// Writing your own engine? The abstract bases live on their own subpath,
// so subclassing costs you nothing but the base classes themselves — no
// concrete engine (and no native SQLite binding) enters your bundle.
import { BaseEngine, SQLEngine } from '@tundralibs/drivers/base';

// Every engine in one barrel — SERVER ONLY. It pulls in the native SQLite
// adapter plus npm:mariadb and npm:mongodb, so an edge or browser bundle
// cannot resolve it; use the per-engine subpaths below there.
import {
  D1Engine,
  MariaEngine,
  MemcachedEngine,
  MongoEngine,
  NeonHttpEngine,
  PostgresEngine,
  RedisEngine,
  SQLiteEngine,
  TursoEngine,
} from '@tundralibs/drivers/engines';

// The package root carries NO engine. It re-exports the same bases as
// /base, the errors and the shared types — nothing that touches a socket
// or a native binding — so it is safe to import from anywhere.
import { EngineError } from '@tundralibs/drivers';
import type { EngineQueryResult } from '@tundralibs/drivers';

import { EngineError } from '@tundralibs/drivers/errors';
import type { EngineOptions, EnginePoolStats } from '@tundralibs/drivers/types';

// Per-engine subpath imports keep tree-shaking happy when you only
// need one engine.
import { MemcachedEngine } from '@tundralibs/drivers/memcached';
import { RedisEngine } from '@tundralibs/drivers/redis';
import { PostgresEngine } from '@tundralibs/drivers/postgres';
import { MariaEngine } from '@tundralibs/drivers/maria';
import { SQLiteEngine } from '@tundralibs/drivers/sqlite';
import { MongoEngine } from '@tundralibs/drivers/mongo';

// Edge / serverless: fetch-only (no sockets, no native binding).
import { NeonHttpEngine } from '@tundralibs/drivers/neon'; // Postgres-over-HTTP
import { TursoEngine } from '@tundralibs/drivers/turso'; // SQLite-over-HTTP
import { D1Engine } from '@tundralibs/drivers/d1'; // SQLite-over-HTTP (Cloudflare D1)

Quick Start

import { MemcachedEngine } from '@tundralibs/drivers/memcached';

const cache = new MemcachedEngine('app-cache', {
  host: 'localhost',
  port: 11211,
  pool: { min: 1, max: 8 },
});

// Auto-connects on first call.
await cache.set('user:1', JSON.stringify({ name: 'Alice' }), 60);
const raw = await cache.get('user:1');
console.log(JSON.parse(raw!));

await cache.disconnect();

Transactions

Wrap work in a callback — the connection is reserved on entry and released on exit (COMMIT if the callback resolves, ROLLBACK if it throws), so it can never leak from the pool:

import { SQLiteEngine } from '@tundralibs/drivers/sqlite';

const engine = new SQLiteEngine('app', { path: './data' });

const rows = await engine.transaction(async (tx) => {
  await tx.execute({
    sql: 'INSERT INTO users (id, email) VALUES (:id:, :email:)',
    params: { id: 1, email: 'ada@x.dev' },
  });
  return await tx.execute({ sql: 'SELECT * FROM users' });
});

Nest with the scope's own transaction() to open a SAVEPOINT: the inner block rolls back to the savepoint on failure — a thrown error or a SQL error such as a constraint violation — while the outer transaction survives, so you can try/catch and carry on:

import { SQLiteEngine } from '@tundralibs/drivers/sqlite';

const engine = new SQLiteEngine('app', { path: './data' });

await engine.transaction(async (tx) => {
  await tx.execute({ sql: 'INSERT INTO orders ...' });
  try {
    await tx.transaction(async (sp) => {
      await sp.execute({ sql: 'INSERT INTO line_items ...' });
      await sp.execute({ sql: 'INSERT INTO line_items ...duplicate' }); // fails
    });
  } catch {
    // only the line_items work rolled back to the savepoint; the order stays
  }
  // outer commits → the order persists
});

Savepoints nest arbitrarily deep (LIFO). The SAVEPOINT syntax is identical across SQLite, PostgreSQL, and MariaDB; MongoDB has no transactions, so transaction() is unavailable there. The lower-level beginTransaction / commitTransaction / createSavepoint primitives exist but are @internal — the callback form is the supported API because it can't leak a connection.

A transaction is bound to a single connection, so run its statements one at a time — await each in turn. Firing overlapping statements on the same scope (e.g. Promise.all([tx.execute(a), tx.execute(b)])) is refused with a TRANSACTION_OPERATION_ERROR rather than allowed to corrupt the wire protocol. Likewise, the tx scope is only valid for the duration of its callback; using it after the callback returns throws TRANSACTION_NOT_FOUND.

Observability

Every engine is an Options/Events subclass and emits its lifecycle as events — which makes the events the integration seam for tracing and metrics, with no dependency on any observability package:

Event Payload Fires
query / slowQuery (instanceId, EngineQueryResult) — carries { id, query, count, time } per query, with duration
transactionBegin / transactionCommit / transactionRollback (instanceId, transactionId) per transaction step
connect / disconnect / connectionFailed (instanceId[, error]) pool lifecycle
error / warn / notice (instanceId, …) failures, warnings, server notices

Attach once per engine at wire-up:

import { SQLiteEngine } from '@tundralibs/drivers/sqlite';

// Your metrics client, whatever it is.
declare const metrics: {
  histogram(name: string): { observe(value: number): void };
};

const engine = new SQLiteEngine('app', { path: './data' });

engine.on('query', (_id, result) => {
  metrics.histogram('db_query_ms').observe(result.time);
});

For distributed tracing, @tundralibs/tracer documents a ready-made recipe that turns these events into correctly-parented CLIENT spans — one handler, zero coupling in either direction: see Tracing drivers without wrapping every call.

EngineQueryResult.query contains the statement. Forwarding it to a log or trace backend can leak user data — sanitise first, or leave it off.

Architecture

    Consumer code (Cacher, NORM, app code)
                  │
    ┌─────────────┼─────────────┐
    │             │             │
Redis,        Postgres,     Mongo,
Memcached     MariaDB,      …                  ← driver engines
              SQLite
    │             │             │
    │         SQLEngine         │              ← +tx, +execute, +stats
    │             │             │
    └─────────────┴─────────────┘
                  │
             BaseEngine                        ← lifecycle + inline pool
                  │
         @tundralibs/compat                    ← cross-runtime TCP

BaseEngine composes an internal ConnectionPool<T> (owned as this._pool, not part of the public API — you never construct or pass a Pool object). With no pool option configured, the engine runs in single-connection mode (one warm connection, no idle eviction) — which is what you want when sitting behind a server-side pooler like PgBouncer / pgcat / RDS Proxy. Configure pool: { min, max, idleTimeoutSeconds, acquireTimeoutSeconds } for multi-connection behavior.

Drivers implement four hooks:

Hook Purpose
_createResource Open one fresh connection
_destroyResource Close one connection
_ping Liveness check on a pooled connection
_validateResource (Optional) Health check before a connection is reused — from the idle list or handed to a queued waiter

Subclasses of SQLEngine add transaction hooks:

Hook Purpose
_execute Run one query on a given client
_beginTransaction Issue BEGIN on the given client
_commitTransaction Issue COMMIT
_rollbackTransaction Issue ROLLBACK
_encodeValue (Optional) Per-value encoding override

The base class handles everything else: status state machine, idempotent connect / disconnect, SSL/TLS option loading, pool min/max/idle/eviction, event emission, named-param rewriting (:name:), slow-query detection, auto-rollback, transaction-timeout enforcement, query stats.

Key Features

Infrastructure

Feature Bun Deno Node.js
Inline connection pool ✅ ✅ ✅
Single-connection mode (no pool cfg) ✅ ✅ ✅
Connection lifecycle state machine ✅ ✅ ✅
Min/max pool sizing ✅ ✅ ✅
Idle eviction (won't drop below min) ✅ ✅ ✅
Acquire-timeout queueing ✅ ✅ ✅
SSL/TLS PEM + file path loading ✅ ✅ ✅
Standardized cross-engine error codes ✅ ✅ ✅
Lifecycle + query events ✅ ✅ ✅
Named-parameter rewriting (:name:) ✅ ✅ ✅
Per-engine value encoding hook ✅ ✅ ✅

Engines

Engine Approach Notes Bun Deno Node.js
Memcached from scratch Text protocol over TCP. ~480 LOC ✅ ✅ ✅
Redis from scratch RESP3 (RESP2 fallback). String / hash / scan / pub-sub ✅ ✅ ✅
Postgres from scratch Wire v3, SCRAM-SHA-256 auth, binary param format. rc ✅ ✅ ✅
MariaDB wraps npm:mariadb Per-connection mode, BaseEngine owns the pool ✅ ✅ ✅
SQLite runtime-branched bun:sqlite / jsr:@db/sqlite / node:sqlite ✅ ✅ ✅*
MongoDB wraps npm:mongodb BaseEngine pool bypassed (MongoClient pools internally) ✅ ✅ ✅
Neon HTTP from scratch Postgres-over-HTTP, fetch-only, one-shot. Edge-safe ✅ ✅ ✅
Turso HTTP from scratch SQLite-over-HTTP (Hrana v3), fetch-only, one-shot. Edge-safe ✅ ✅ ✅
D1 HTTP from scratch SQLite-over-HTTP (D1 REST), fetch-only, one-shot. Edge-safe ✅ ✅ ✅

*Node SQLite uses the built-in node:sqlite (Node 22+) by default and falls back to npm:better-sqlite3 if the built-in is missing. The fallback is an optional dependency.

Standardized error codes

Every engine maps native errors to standardized codes in EngineError.code. This enables consistent error handling across different database systems.

Key error codes:

INVALID_AUTH, PERMISSION_DENIED, DATABASE_NOT_FOUND, TABLE_NOT_FOUND, COLUMN_NOT_FOUND, DUPLICATE_KEY, FOREIGN_KEY_VIOLATION, NOT_NULL_VIOLATION, CHECK_VIOLATION, SYNTAX_ERROR, DEADLOCK, LOCK_TIMEOUT, QUERY_TIMEOUT, SERIALIZATION_FAILURE, CONNECTION_LOST, QUERY_EXECUTION_FAILED, MISSING_PARAMETERS, TRANSACTION_NOT_FOUND, TRANSACTION_OPERATION_ERROR, OPERATION_FAILED, UNSUPPORTED_OPERATION.

See Error Handling for complete documentation of all error codes, handling patterns, and examples.

License

MIT

Clone this wiki locally