База даних
Один 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 за замовчуванням |
|---|---|---|---|
sqlite | bun:sqlite | вбудовано в Bun | .hopak/data.db |
postgres | postgres (postgres.js) | hopak use postgres | process.env.DATABASE_URL |
mysql | mysql2 | hopak use mysql | process.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 posts
WHERE published = true
GROUP BY day
ORDER BY day DESC
LIMIT 30
`;
// Точковий пошук — ${id} стає bound-параметром, не вставляється у SQL
const post = await ctx.db!.sql<Post>`SELECT * FROM posts WHERE id = ${id}`;
// DDL і записи теж; записи повертають порожній масив
await ctx.db!.sql`ALTER TABLE posts ADD COLUMN views INTEGER NOT NULL DEFAULT 0`;
await ctx.db!.sql`UPDATE posts SET published = ${true} WHERE author_id = ${userId}`;
Інтерполяції стають driver-native placeholder-ами (? на SQLite і MySQL, $N на Postgres). Значення ніколи не concat-яться у текст SQL, тож injection через ${...} тут принципово неможлива — безпечно навіть якщо одне з інтерпольованих значень приходить від користувача.
Назви таблиць у сирому SQL
Додано у 1.0
Фізична таблиця — це назва моделі у множині: модель post → таблиця posts, category → categories. Множина утворюється за суфіксом, тож person стає persons, а не people.
Не вгадуйте — беріть назву з самої моделі; це ж потрібно, коли Ви складаєте DDL у міграції:
import post from '../models/post';
post.tableName; // 'posts'
Tagged template не вміє інтерполювати ідентифікатор — sql`SELECT * FROM ${post.tableName}` прив’яже назву як значення, а не як таблицю. У шаблоні пишіть назву таблиці буквально, а tableName використовуйте там, де будуєте текст statement-а вручну (ctx.execute у міграціях).
Postgres повертає BIGINT рядком
COUNT(*), SUM(...) та інші bigint-результати приходять у сирому SQL із Postgres-драйвера як рядки — так postgres.js зберігає точність, Hopak тут нічого не перетворює. Типізований клієнт (client.count(), client.aggregate()) уже нормалізує їх до чисел; у сирому SQL приводьте самі:
const [row] = await ctx.db!.sql<{ n: string }>`SELECT COUNT(*) AS n FROM posts`;
const total = Number(row?.n ?? 0);
Читання повертає типізований 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)
Прибрано у 1.0
Про
db.execute. Ранні релізи малиdb.execute(sql, params?)як write-шлях сирого SQL. У 0.5 його позначено deprecated, а в 1.0 прибрано з публічного інтерфейсуDatabase— використовуйтеdb.sql. Усередині міграційctx.executeзалишається доступним для динамічного DDL-тексту, який tagged template свідомо відмовляється приймати.