База даних
Один 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 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у міграціях).