Bun + SQLite in der Praxis

Datenbank-Patterns für schnelle APIs und Serverless

7 Minuten
Bun + SQLite in der Praxis
#Bun #SQLite #Datenbank #TypeScript

SQLite ist in Bun nativ integriert. Kein npm install, keine nativen Bindings, keine Kompilierung. Das bun:sqlite-Modul ist 3-6x schneller als Alternativen wie better-sqlite3.

Grundlagen

Eine Datenbank öffnen:

import { Database } from "bun:sqlite";

// Datei-Datenbank
const db = new Database("app.db");

// In-Memory für Tests
const memDb = new Database(":memory:");

// Readonly
const readonlyDb = new Database("app.db", { readonly: true });

Queries ausführen

Einfache Queries:

// Alle Ergebnisse
const users = db.query("SELECT * FROM users").all();

// Ein Ergebnis
const user = db.query("SELECT * FROM users WHERE id = 1").get();

// Ausführen ohne Rückgabe
db.run("DELETE FROM sessions WHERE expires < datetime('now')");

Mit Parametern:

// Positional Parameters
const user = db.query("SELECT * FROM users WHERE id = ?").get(42);

// Named Parameters
const user = db.query("SELECT * FROM users WHERE email = $email").get({
  $email: "[email protected]",
});

// Mehrere Parameter
db.run(
  "INSERT INTO users (name, email) VALUES (?, ?)",
  "Alice",
  "[email protected]"
);

Prepared Statements

Für wiederholte Queries sind Prepared Statements schneller:

const findUser = db.prepare("SELECT * FROM users WHERE id = ?");

// Wiederverwendbar
const user1 = findUser.get(1);
const user2 = findUser.get(2);
const user3 = findUser.get(3);

Das Statement wird einmal geparst und kann beliebig oft ausgeführt werden.

Type-Safety mit TypeScript

Generics für typisierte Ergebnisse:

interface User {
  id: number;
  name: string;
  email: string;
  created_at: string;
}

const findUser = db.prepare<User, [number]>(
  "SELECT * FROM users WHERE id = ?"
);

const user = findUser.get(42);
// user ist vom Typ User | null

Query-Builder Pattern:

class UserRepository {
  private db: Database;
  private findById = this.db.prepare<User, [number]>(
    "SELECT * FROM users WHERE id = ?"
  );
  private findByEmail = this.db.prepare<User, [string]>(
    "SELECT * FROM users WHERE email = ?"
  );

  constructor(db: Database) {
    this.db = db;
  }

  getById(id: number): User | null {
    return this.findById.get(id);
  }

  getByEmail(email: string): User | null {
    return this.findByEmail.get(email);
  }
}

Transactions

Für atomare Operationen:

const insertUsers = db.transaction((users: { name: string; email: string }[]) => {
  const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
  
  for (const user of users) {
    insert.run(user.name, user.email);
  }
  
  return users.length;
});

// Alles oder nichts
const count = insertUsers([
  { name: "Alice", email: "[email protected]" },
  { name: "Bob", email: "[email protected]" },
]);

Bei einem Fehler wird automatisch ein Rollback durchgeführt.

Verschachtelte Transactions:

const outer = db.transaction(() => {
  db.run("INSERT INTO logs (message) VALUES ('start')");
  
  const inner = db.transaction(() => {
    db.run("INSERT INTO users (name) VALUES ('test')");
  });
  
  inner(); // Savepoint
  
  db.run("INSERT INTO logs (message) VALUES ('end')");
});

outer();

Datentypen

SQLite-Typen werden automatisch konvertiert:

SQLiteJavaScript
INTEGERnumber
REALnumber
TEXTstring
BLOBUint8Array
NULLnull

BLOB-Handling:

// Schreiben
const data = new Uint8Array([1, 2, 3, 4]);
db.run("INSERT INTO files (content) VALUES (?)", data);

// Lesen
const row = db.query("SELECT content FROM files WHERE id = 1").get();
console.log(row.content); // Uint8Array

JSON-Spalten:

interface Settings {
  theme: string;
  notifications: boolean;
}

// Als TEXT speichern
db.run(
  "INSERT INTO users (settings) VALUES (?)",
  JSON.stringify({ theme: "dark", notifications: true })
);

// Beim Lesen parsen
const row = db.query("SELECT settings FROM users WHERE id = 1").get();
const settings: Settings = JSON.parse(row.settings);

Performance-Patterns

Batch-Inserts:

const insertMany = db.transaction((items: string[]) => {
  const stmt = db.prepare("INSERT INTO items (name) VALUES (?)");
  for (const item of items) {
    stmt.run(item);
  }
});

// 10.000 Inserts in einer Transaction
insertMany(Array.from({ length: 10000 }, (_, i) => `item-${i}`));

WAL-Modus für Concurrency:

db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL");

WAL (Write-Ahead Logging) ermöglicht gleichzeitige Reads während eines Writes.

Indizes:

db.run("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)");

API-Beispiel mit Hono

Tipp: Für einen umfassenden Einstieg in Hono siehe Ultra-performante Web-APIs mit Hono.

import { Hono } from "hono";
import { Database } from "bun:sqlite";

const db = new Database("app.db");
const app = new Hono();

// Prepared Statements
const getUsers = db.prepare("SELECT id, name, email FROM users LIMIT ? OFFSET ?");
const getUser = db.prepare("SELECT * FROM users WHERE id = ?");
const createUser = db.prepare("INSERT INTO users (name, email) VALUES (?, ?) RETURNING *");

app.get("/users", (c) => {
  const limit = Number(c.req.query("limit")) || 10;
  const offset = Number(c.req.query("offset")) || 0;
  return c.json(getUsers.all(limit, offset));
});

app.get("/users/:id", (c) => {
  const user = getUser.get(Number(c.req.param("id")));
  if (!user) return c.json({ error: "Not found" }, 404);
  return c.json(user);
});

app.post("/users", async (c) => {
  const { name, email } = await c.req.json();
  const user = createUser.get(name, email);
  return c.json(user, 201);
});

export default app;

Migrations

Ein einfaches Migrations-Pattern:

const migrations = [
  `CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT UNIQUE NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
  )`,
  `CREATE TABLE IF NOT EXISTS posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER REFERENCES users(id),
    title TEXT NOT NULL,
    content TEXT,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
  )`,
  `CREATE INDEX IF NOT EXISTS idx_posts_user ON posts(user_id)`,
];

function migrate(db: Database) {
  db.run(`CREATE TABLE IF NOT EXISTS _migrations (
    id INTEGER PRIMARY KEY,
    applied_at TEXT DEFAULT CURRENT_TIMESTAMP
  )`);

  const applied = db.query("SELECT id FROM _migrations").all().map(r => r.id);

  for (let i = 0; i < migrations.length; i++) {
    if (!applied.includes(i)) {
      db.run(migrations[i]);
      db.run("INSERT INTO _migrations (id) VALUES (?)", i);
      console.log(`Applied migration ${i}`);
    }
  }
}

migrate(db);

Quellen