playbook/outfitter-agents/plugins/outfitter/skills/bun-dev/examples/database-crud.md

11 KiB

SQLite CRUD Patterns

Complete examples for database operations with bun:sqlite.

Basic Repository

import { Database } from 'bun:sqlite';

type User = {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
};

type UserRow = {
  id: string;
  email: string;
  name: string;
  created_at: string;
};

class UserRepository {
  private stmt: {
    findById: ReturnType<Database['prepare']>;
    findByEmail: ReturnType<Database['prepare']>;
    findAll: ReturnType<Database['prepare']>;
    create: ReturnType<Database['prepare']>;
    update: ReturnType<Database['prepare']>;
    delete: ReturnType<Database['prepare']>;
  };

  constructor(private db: Database) {
    // Initialize schema
    this.db.run(`
      CREATE TABLE IF NOT EXISTS users (
        id TEXT PRIMARY KEY,
        email TEXT UNIQUE NOT NULL,
        name TEXT NOT NULL,
        created_at TEXT DEFAULT CURRENT_TIMESTAMP
      )
    `);

    // Prepare statements once
    this.stmt = {
      findById: this.db.prepare('SELECT * FROM users WHERE id = ?'),
      findByEmail: this.db.prepare('SELECT * FROM users WHERE email = ?'),
      findAll: this.db.prepare('SELECT * FROM users ORDER BY created_at DESC LIMIT ?'),
      create: this.db.prepare('INSERT INTO users (id, email, name) VALUES (?, ?, ?) RETURNING *'),
      update: this.db.prepare('UPDATE users SET email = ?, name = ? WHERE id = ? RETURNING *'),
      delete: this.db.prepare('DELETE FROM users WHERE id = ? RETURNING *')
    };
  }

  findById(id: string): User | null {
    const row = this.stmt.findById.get(id) as UserRow | null;
    return row ? this.mapRow(row) : null;
  }

  findByEmail(email: string): User | null {
    const row = this.stmt.findByEmail.get(email) as UserRow | null;
    return row ? this.mapRow(row) : null;
  }

  findAll(limit = 100): User[] {
    const rows = this.stmt.findAll.all(limit) as UserRow[];
    return rows.map(row => this.mapRow(row));
  }

  create(data: { email: string; name: string }): User {
    const id = crypto.randomUUID();
    const row = this.stmt.create.get(id, data.email, data.name) as UserRow;
    return this.mapRow(row);
  }

  update(id: string, data: { email?: string; name?: string }): User | null {
    const existing = this.findById(id);
    if (!existing) return null;

    const row = this.stmt.update.get(
      data.email ?? existing.email,
      data.name ?? existing.name,
      id
    ) as UserRow;
    return this.mapRow(row);
  }

  delete(id: string): boolean {
    const row = this.stmt.delete.get(id);
    return row !== null;
  }

  private mapRow(row: UserRow): User {
    return {
      id: row.id,
      email: row.email,
      name: row.name,
      createdAt: new Date(row.created_at)
    };
  }
}

Usage

const db = new Database('app.db');
const users = new UserRepository(db);

// Create
const user = users.create({
  email: 'alice@example.com',
  name: 'Alice'
});
console.log('Created:', user.id);

// Read
const found = users.findById(user.id);
console.log('Found:', found?.email);

// Update
const updated = users.update(user.id, { name: 'Alice Smith' });
console.log('Updated:', updated?.name);

// Delete
const deleted = users.delete(user.id);
console.log('Deleted:', deleted);

// List
const allUsers = users.findAll(10);
console.log('All users:', allUsers.length);

db.close();

With Transactions

class AccountRepository {
  constructor(private db: Database) {
    this.db.run(`
      CREATE TABLE IF NOT EXISTS accounts (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        balance INTEGER NOT NULL DEFAULT 0,
        FOREIGN KEY (user_id) REFERENCES users(id)
      )
    `);
  }

  transfer = this.db.transaction((fromId: string, toId: string, amount: number) => {
    // Check balance
    const from = this.db.prepare('SELECT balance FROM accounts WHERE id = ?').get(fromId) as { balance: number } | null;

    if (!from) {
      throw new Error('Source account not found');
    }

    if (from.balance < amount) {
      throw new Error('Insufficient funds');
    }

    // Debit
    this.db.prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?').run(amount, fromId);

    // Credit
    this.db.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?').run(amount, toId);

    return { fromId, toId, amount };
  });

  bulkCreate = this.db.transaction((accounts: Array<{ userId: string; balance: number }>) => {
    const stmt = this.db.prepare('INSERT INTO accounts (id, user_id, balance) VALUES (?, ?, ?)');

    const created = [];
    for (const account of accounts) {
      const id = crypto.randomUUID();
      stmt.run(id, account.userId, account.balance);
      created.push(id);
    }

    return created;
  });
}

Pagination

type PaginatedResult<T> = {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
};

class UserRepository {
  // ... other methods

  findPaginated(page: number, pageSize: number): PaginatedResult<User> {
    const offset = (page - 1) * pageSize;

    const countResult = this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
    const total = countResult.count;

    const rows = this.db.prepare(`
      SELECT * FROM users
      ORDER BY created_at DESC
      LIMIT ? OFFSET ?
    `).all(pageSize, offset) as UserRow[];

    return {
      items: rows.map(row => this.mapRow(row)),
      total,
      page,
      pageSize,
      totalPages: Math.ceil(total / pageSize)
    };
  }
}

// Usage
const result = users.findPaginated(1, 20);
console.log(`Page ${result.page} of ${result.totalPages}`);
console.log(`Showing ${result.items.length} of ${result.total} users`);

Search with Full-Text

class PostRepository {
  constructor(private db: Database) {
    // Create main table
    this.db.run(`
      CREATE TABLE IF NOT EXISTS posts (
        id TEXT PRIMARY KEY,
        title TEXT NOT NULL,
        content TEXT NOT NULL,
        user_id TEXT NOT NULL,
        created_at TEXT DEFAULT CURRENT_TIMESTAMP
      )
    `);

    // Create FTS index
    this.db.run(`
      CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
        title,
        content,
        content='posts',
        content_rowid='rowid'
      )
    `);

    // Triggers to keep FTS in sync
    this.db.run(`
      CREATE TRIGGER IF NOT EXISTS posts_ai AFTER INSERT ON posts BEGIN
        INSERT INTO posts_fts(rowid, title, content)
        VALUES (new.rowid, new.title, new.content);
      END
    `);

    this.db.run(`
      CREATE TRIGGER IF NOT EXISTS posts_ad AFTER DELETE ON posts BEGIN
        INSERT INTO posts_fts(posts_fts, rowid, title, content)
        VALUES ('delete', old.rowid, old.title, old.content);
      END
    `);

    this.db.run(`
      CREATE TRIGGER IF NOT EXISTS posts_au AFTER UPDATE ON posts BEGIN
        INSERT INTO posts_fts(posts_fts, rowid, title, content)
        VALUES ('delete', old.rowid, old.title, old.content);
        INSERT INTO posts_fts(rowid, title, content)
        VALUES (new.rowid, new.title, new.content);
      END
    `);
  }

  search(query: string, limit = 20) {
    return this.db.prepare(`
      SELECT posts.*, bm25(posts_fts) as rank
      FROM posts
      JOIN posts_fts ON posts.rowid = posts_fts.rowid
      WHERE posts_fts MATCH ?
      ORDER BY rank
      LIMIT ?
    `).all(query, limit);
  }
}

// Usage
const posts = new PostRepository(db);
const results = posts.search('typescript tutorial');

JSON Storage

type Settings = {
  theme: 'light' | 'dark';
  notifications: boolean;
  language: string;
};

class SettingsRepository {
  constructor(private db: Database) {
    this.db.run(`
      CREATE TABLE IF NOT EXISTS settings (
        user_id TEXT PRIMARY KEY,
        data TEXT NOT NULL
      )
    `);
  }

  get(userId: string): Settings | null {
    const row = this.db.prepare('SELECT data FROM settings WHERE user_id = ?').get(userId) as { data: string } | null;

    if (!row) return null;
    return JSON.parse(row.data);
  }

  set(userId: string, settings: Settings): void {
    this.db.prepare(`
      INSERT INTO settings (user_id, data) VALUES (?, ?)
      ON CONFLICT (user_id) DO UPDATE SET data = excluded.data
    `).run(userId, JSON.stringify(settings));
  }

  update(userId: string, partial: Partial<Settings>): Settings | null {
    const existing = this.get(userId);
    if (!existing) return null;

    const updated = { ...existing, ...partial };
    this.set(userId, updated);
    return updated;
  }

  // Query JSON fields directly
  findByTheme(theme: 'light' | 'dark') {
    return this.db.prepare(`
      SELECT user_id FROM settings
      WHERE json_extract(data, '$.theme') = ?
    `).all(theme);
  }
}

With Hono API

import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { Database } from 'bun:sqlite';
import { createFactory } from 'hono/factory';
import { HTTPException } from 'hono/http-exception';

type Env = {
  Variables: {
    db: Database;
    users: UserRepository;
  };
};

const factory = createFactory<Env>();

const dbMiddleware = factory.createMiddleware(async (c, next) => {
  const db = new Database('app.db');
  const users = new UserRepository(db);

  c.set('db', db);
  c.set('users', users);

  try {
    await next();
  } finally {
    db.close();
  }
});

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100)
});

const UpdateUserSchema = z.object({
  email: z.string().email().optional(),
  name: z.string().min(1).max(100).optional()
});

const app = factory.createApp()
  .use('*', dbMiddleware)
  .get('/users', (c) => {
    const users = c.get('users');
    const limit = Number(c.req.query('limit')) || 20;
    return c.json({ users: users.findAll(limit) });
  })
  .get('/users/:id', (c) => {
    const users = c.get('users');
    const user = users.findById(c.req.param('id'));

    if (!user) {
      throw new HTTPException(404, { message: 'User not found' });
    }

    return c.json({ user });
  })
  .post('/users', zValidator('json', CreateUserSchema), (c) => {
    const users = c.get('users');
    const data = c.req.valid('json');

    const existing = users.findByEmail(data.email);
    if (existing) {
      throw new HTTPException(409, { message: 'Email already registered' });
    }

    const user = users.create(data);
    return c.json({ user }, 201);
  })
  .patch('/users/:id', zValidator('json', UpdateUserSchema), (c) => {
    const users = c.get('users');
    const data = c.req.valid('json');

    const user = users.update(c.req.param('id'), data);

    if (!user) {
      throw new HTTPException(404, { message: 'User not found' });
    }

    return c.json({ user });
  })
  .delete('/users/:id', (c) => {
    const users = c.get('users');
    const deleted = users.delete(c.req.param('id'));

    if (!deleted) {
      throw new HTTPException(404, { message: 'User not found' });
    }

    return c.json({ deleted: true });
  });

export default app;