Документація / База даних

База даних

Один API для SQLite, Postgres, MySQL. Запити, зв'язки, транзакції.

Підтримуються три SQL-діалекти, один API. SQLite постачається з Bun (без встановлення); Postgres та MySQL — опціональні через hopak use postgres / hopak use mysql. Під капотом Drizzle, але ви не пишете Drizzle — ви пишете моделі.

Схема створюється при першому старті hopak dev через CREATE TABLE IF NOT EXISTS для кожної моделі. Запустіть hopak sync, щоб зробити те ж саме явно без старту сервера (корисно в CI або на свіжій базі Postgres / MySQL). Це ідемпотентне наївне відтворення — ту саму команду безпечно виконувати повторно, але вона не обробляє зміни схеми (ALTER TABLE / RENAME / DROP). Для еволюції схеми під час прототипування — видаліть таблицю і виконайте sync знову.

Матриця діалектів

ДіалектДрайверВстановленняФайл / URL за замовчуванням
sqlitebun:sqliteвбудовано в Bun.hopak/data.db
postgrespostgres (postgres.js)hopak use postgresprocess.env.DATABASE_URL
mysqlmysql2hopak use mysqlprocess.env.DATABASE_URL

Запити всередині хендлера

defineRoute({
  handler: async (ctx) => {
    const post = await ctx.db?.model('post').findOne(1);
    return post;
  },
});

ctx.db дорівнює undefined, коли сервер стартує без моделей. У звичайному застосунку він завжди встановлений — ctx.db!.model(...) безпечно звужує тип, або if (!ctx.db) throw new InternalError(...) для явної перевірки.

Повна поверхня ModelClient

Кожен db.model(name) повертає типізованого клієнта з такою поверхнею:

// Read
client.findMany(options?);                           // → TRow[]
client.findMany({ select: ['id', 'title'] });        // → Pick<TRow, ...>[]
client.findOne(id, { lock?: 'forUpdate' | 'forShare' });  // → TRow | null
client.findOrFail(id, options?);                     // → TRow (throws NotFound)
client.count({ where? });                            // → number

// Write
client.create(data);                                 // → TRow
client.update(id, data);                             // → TRow (throws NotFound)
client.delete(id);                                   // → boolean
client.upsert({ where, create, update });            // → TRow

// Batch
client.createMany([row, row, row]);                  // → { count }
client.updateMany({ where, data });                  // → { count }
client.deleteMany({ where });                        // → { count }

// Aggregate
client.aggregate({ sum, avg, min, max, count });     // → AggregateResult
client.aggregate({ groupBy: ['col'], ... });         // → Array<AggregateResult + group keys>

Довідник опцій findMany

{
  where?: WhereClause,       // filter — see recipe 10
  include?: IncludeClause,   // eager-load relations — see recipe 11
  select?: ['id', 'title'],  // projection — see recipe 16
  distinct?: true | ['col'], // dedupe — see recipe 16
  orderBy?: [{ field, direction }],
  limit?: number,
  offset?: number,
  cursor?: { id: 42 },       // keyset pagination — see recipe 14
  lock?: 'forUpdate' | 'forShare',  // row locks — see recipe 15
}

Транзакції

await ctx.db.transaction(async (tx) => {
  const user = await tx.model('user').create({...});
  await tx.model('post').create({ author: user.id, ... });
  // Throwing here (or from any tx query) rolls everything back.
});

Див. Recipe 15 для патернів блокування FOR UPDATE.

Сирий SQL через db.sql

Для тих ~5% запитів, які типізований клієнт не покриває — window-функції, рекурсивні CTE, JSON / FTS окремих діалектів, EXPLAIN ANALYZE, DDL — використовуйте db.sql, tagged template який параметризує інтерполяції автоматично:

// Типізований agg із group-by по обчислюваній колонці
const daily = await ctx.db!.sql<{ day: string; n: number }>`
  SELECT DATE(created_at) AS day, COUNT(*) AS n
  FROM post
  WHERE published = true
  GROUP BY day
  ORDER BY day DESC
  LIMIT 30
`;

// Точковий пошук — ${id} стає bound-параметром, не вставляється у SQL
const post = await ctx.db!.sql<Post>`SELECT * FROM post WHERE id = ${id}`;

// DDL і записи теж; записи повертають порожній масив
await ctx.db!.sql`ALTER TABLE post ADD COLUMN views INTEGER NOT NULL DEFAULT 0`;
await ctx.db!.sql`UPDATE post SET published = ${true} WHERE author_id = ${userId}`;

Інтерполяції стають driver-native placeholder-ами (? на SQLite і MySQL, $N на Postgres). Значення ніколи не concat-яться у текст SQL, тож injection через ${...} тут принципово неможлива — безпечно навіть якщо одне з інтерпольованих значень приходить від користувача.

Читання повертає типізований readonly T[]. Записи (INSERT / UPDATE / DELETE / DDL) повертають порожній масив; повернене значення має сенс лише для SELECT.

db.sql працює і всередині db.transaction(fn) — у колбеку використовуйте tx.sql точно так само, як db.sql зовні; statement прив’язується до поточної транзакції.

Escape на Drizzle (db.builder)

Для <1% випадків, де навіть db.sql замало (композиція query-білдера, Drizzle-плагіни, спеціальні фічі) — переходьте безпосередньо на Drizzle:

import { sql } from 'drizzle-orm';

const drizzle = ctx.db!.builder();
const top = await drizzle.execute(sql`
  SELECT author, ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) AS rank
  FROM posts WHERE published = true GROUP BY author LIMIT 10
`);

builder() повертає нативний Drizzle-екземпляр діалекту — доступний увесь API Drizzle. SAVEPOINT і власні рівні ізоляції живуть тут.

Ієрархія escape

model()           ← 90% запитів (типізовано + safe за замовчуванням)

db.sql`...`       ← сирий SQL із типами (≈5% escape)

db.builder()      ← Drizzle query builder (<1% escape)

Про db.execute. Ранні релізи мали db.execute(sql, params?) як write-шлях сирого SQL. У 0.5 він позначений @deprecated і forward-ить до db.sql — існуючі файли міграцій компілюються без змін. Новий код пише db.sql (або ctx.sql у міграціях).