Рецепти
25 рецептів покроково — REST, валідація, зв'язки, auth, міграції.
Типові бекенд-задачі, покроково. Кожен рецепт показує, де лежить файл, код, як це запустити та що ви маєте побачити. Починайте з щойно згенерованого проєкту:
hopak new my-app # SQLite by default (zero-install, works offline)
cd my-app
hopak new виконує bun install за вас — без окремого кроку. Хочете інший діалект одразу? Передайте --db:
hopak new my-app --db postgres # or --db mysql / --db sqlite
Вибір діалекту наперед записує правильний блок database: у hopak.config.ts, додає драйвер у package.json і кладе плейсхолдер DATABASE_URL у .env.example. Див. Recipe 17 для повного сценарію обох варіантів — hopak new --db та hopak use.
Кожен рецепт нижче передбачає SQLite за замовчуванням, якщо не зазначено інакше — поведінка в рантаймі однакова на кожному діалекті, тож приклади коду перенесені copy-paste.
Зміст
- Створити REST-ресурс
- Валідація вхідних даних
- Приховати чутливі поля
- Додати власний endpoint
- Кастомізувати один CRUD-endpoint
- Кинути типізовану помилку
- Оголосити власну помилку
- Запит до БД усередині handler-а
- Зв’язки між моделями
- Фільтри з операторами —
gte,like,in,between,OR,NOT - Завантажити зв’язані записи через
include— без N+1 - Upsert та масові записи
- Агрегати — sum, avg, count (опціонально з
groupBy) - Cursor-пагінація (keyset)
- Транзакції та row-локи
- Вибрати конкретні колонки —
select,distinct - Вибрати або змінити базу даних
- Увімкнути HTTPS у локальному dev
- Дозволити CORS з Вашого фронтенду
- Обслуговувати статичні файли
- Перемістити вихідний код в іншу директорію
- Генерація файлів через CLI
- Логувати кожен запит (з correlation-id)
- Додати JWT-аутентифікацію (signup, login,
me, захищені routes) - Еволюціонувати схему через міграції
1. Створити REST-ресурс
Мета: відкрити GET/POST /api/posts та GET/PUT/PATCH/DELETE /api/posts/:id.
1. Згенеруйте файли моделі + CRUD-маршрутів:
hopak generate model post
hopak generate crud post
Перша команда створює app/models/post.ts. Друга створює два файли маршрутів — app/routes/api/posts.ts (list + create) та app/routes/api/posts/[id].ts (read + replace + patch + delete). Відкрийте будь-який з них; уся REST-поверхня — це звичайний код, який можна читати й редагувати — нічого не синтезується в рантаймі.
2. Додайте поля до моделі:
// app/models/post.ts
import { model, text, boolean } from '@hopak/core';
export default model('post', {
title: text().required().min(3),
content: text().required(),
published: boolean().default(false),
});
3. Запустіть сервер:
hopak dev
При першому запуску Hopak створює SQLite-файл у .hopak/data.db і виконує CREATE TABLE IF NOT EXISTS для кожної моделі. Безпечно повторювати — hopak sync робить те саме явно, якщо ви хочете відокремити синхронізацію схеми від старту сервера (зручно для CI або свіжої Postgres / MySQL бази).
4. Спробуйте з іншого терміналу:
curl -X POST http://localhost:3000/api/posts \
-H 'content-type: application/json' \
-d '{"title":"Hello","content":"World"}'
Очікувана відповідь (201 Created):
{ "id": 1, "title": "Hello", "content": "World", "published": false,
"createdAt": "...", "updatedAt": "..." }
5. Отримайте список:
curl http://localhost:3000/api/posts
# → { "items": [...], "total": 1, "limit": 20, "offset": 0 }
curl 'http://localhost:3000/api/posts?limit=5&offset=10'
# pagination via query string; limit defaults to 20, max 100
6. Перевірте, що саме зареєстровано:
hopak check
# ✓ Models 1 loaded (post)
# ✓ Routes 6 file route(s)
Шість ендпоїнтів з двох згенерованих файлів: list, read, create, replace, patch, delete — усі з пагінацією та валідацією. Сегмент у множині (/api/posts) береться з pluralize('post') — нерегулярні множини обробляються (story → stories, box → boxes). Не потрібні ендпоїнти для певної моделі? Просто не запускайте для неї hopak generate crud — модель усе одно стане таблицею, ви просто не виставляєте HTTP-маршрути.
2. Валідація вхідних даних
Мета: відхиляти некоректні запити з чіткими повідомленнями про помилку по кожному полю.
Валідація генерується з моделі — ви не пишете окрему схему. POST і PUT валідують повний об’єкт; PATCH валідує частковий автоматично.
1. Додайте обмеження:
// app/models/user.ts
import { model, text, email, enumOf, number } from '@hopak/core';
export default model('user', {
name: text().required().min(2).max(100),
email: email().required().unique(),
age: number().optional().min(18).max(120),
role: enumOf('admin', 'user', 'guest').default('user'),
});
2. Надішліть некоректний запит:
curl -X POST http://localhost:3000/api/users \
-H 'content-type: application/json' \
-d '{"name":"X","email":"not-an-email","age":5,"role":"superman"}'
Відповідь (400 Bad Request):
{
"error": "VALIDATION_ERROR",
"message": "Invalid body",
"details": {
"name": ["Invalid length: Expected >=2 but received 1"],
"email": ["Invalid email: Received \"not-an-email\""],
"age": ["Invalid value: Expected >=18 but received 5"],
"role": ["Invalid type: Expected (\"admin\" | \"user\" | \"guest\") but received \"superman\""]
}
}
Кожне поле з помилкою має масив читабельних повідомлень під details.
.unique() — де відбувається перевірка
.unique() — це обмеження на рівні БД, його забезпечує SQLite при вставці рядка. Hopak перехоплює UNIQUE constraint failed і повертає 409 Conflict, а не 400:
{ "error": "CONFLICT", "message": "Unique constraint violated" }
Отже, від виклику create очікуйте дві форми відповіді:
| Некоректний ввід | Відповідь |
|---|---|
| Неправильна форма / тип / діапазон | 400 VALIDATION_ERROR з details |
| Форма валідна, але вже існує | 409 CONFLICT |
Валідація у власному маршруті
Коли пишете свій handler, валідуйте тією ж схемою, яку використовують CRUD-хелпери:
// app/routes/api/signup.ts
import {
buildModelSchema,
defineRoute,
serializeForResponse,
validate,
ValidationError,
} from '@hopak/core';
import user from '../../models/user';
const schema = buildModelSchema(user, { omitId: true });
export const POST = defineRoute({
handler: async (ctx) => {
const result = validate(schema, await ctx.body());
if (!result.ok) {
throw new ValidationError('Invalid signup', result.errors);
}
const row = await ctx.db!.model('user').create(result.data);
// `serializeForResponse` strips `password` / `secret` / `token`
// columns. The `crud.*` helpers do this automatically; a hand-
// written handler has to call it explicitly or the hash leaks.
return serializeForResponse(row, user);
},
});
buildModelSchema(model, { partial: true }) дає схему у стилі PATCH. result.errors — це Record<field, string[]>, та сама форма, яку повертають CRUD-хелпери.
Чутливі поля у власних маршрутах:
password(),secret()таtoken()видаляються серіалізатором, іcrud.list/crud.create/ тощо пропускають кожен рядок через нього. Власний handler, який повертає рядок моделі напряму —return ctx.db!.model('user').findOne(id)— цей крок пропускає, і колонка з БД (argon2-хеш, API-ключ тощо) опиниться у відповіді. Завжди обгортайте рядок уserializeForResponse(row, model)перед поверненням.
Кидайте власні помилки по полях
Якщо правило доменне (не обмеження поля), киньте ValidationError з мапою details — рендериться ідентично:
if (body.password === body.email) {
throw new ValidationError('Invalid body', {
password: ['Password must differ from email'],
});
}
3. Приховати чутливі поля
Мета: зберігати паролі та API-токени в базі, але ніколи не повертати їх у відповідях.
Три типи полів позначені Hopak як чутливі й видаляються з кожної JSON-відповіді:
| Поле | Призначення |
|---|---|
password() | Паролі для входу (зберігаються як звичайний рядок — хешуйте самі перед вставкою) |
secret() | Ключі для підпису, OAuth client secrets, внутрішні токени |
token() | API-ключі, bearer-токени, refresh-токени |
Виключення відбувається в серіалізаторі для кожного CRUD-ендпоїнту: list, single, відповідь на create, відповідь на update. Також воно застосовується до будь-якого значення, яке ви повертаєте з власного маршруту, що містить одну з цих колонок (наприклад return await ctx.db.model('user').findOne(1)).
// app/models/user.ts
import { model, text, email, password, token } from '@hopak/core';
export default model('user', {
name: text().required(),
email: email().required().unique(),
password: password().required().min(8),
apiKey: token().optional(),
});
Перевірте:
curl -X POST http://localhost:3000/api/users \
-H 'content-type: application/json' \
-d '{"name":"Alice","email":"a@b.com","password":"secret12","apiKey":"tok_abc"}'
Відповідь — зверніть увагу, що password і apiKey відсутні, хоча їх збережено:
{ "id": 1, "name": "Alice", "email": "a@b.com",
"createdAt": "...", "updatedAt": "..." }
Те саме при GET /api/users/1.
Читання значення на сервері
Поле не видаляється з бази — лише з JSON-виводу. Серверний код усе ще його бачить:
const row = await ctx.db?.model('user').findOrFail(id);
// row.password is the string that was stored — use it for auth:
const ok = await Bun.password.verify(submitted, row.password);
Просто не повертайте return row напряму після звернення до row.password — серіалізатор усе одно прибере поле, але звичка, яку варто виробити, — ніколи не включати його в API-поверхню.
Запис значення
Тіла POST / PATCH приймають поле як звичайне — валідація все одно виконується (.min(8) тощо). Хешуйте перед вставкою через Bun.password.hash(plain) (argon2id за замовчуванням) у власному маршруті або pre-insert-хуку.
Hopak не хешує автоматично.
password()означає не витікай при читанні, а не шифруй при записі.
4. Додати власний endpoint
Мета: створити POST /posts/:id/publish, який перемикає прапорець published.
URL виводиться з шляху файлу під app/routes/. Квадратні дужки позначають динамічні сегменти.
// app/routes/posts/[id]/publish.ts
import { defineRoute, NotFound } from '@hopak/core';
export const POST = defineRoute({
handler: async (ctx) => {
const id = Number(ctx.params.id);
const post = await ctx.db?.model('post').findOrFail(id);
const updated = await ctx.db?.model('post').update(id, { published: true });
return { previous: post.published, updated };
},
});
Тест:
curl -X POST http://localhost:3000/posts/1/publish
# → { "previous": false, "updated": { "id": 1, "published": true, ... } }
Шлях файлу → URL
| Файл | URL |
|---|---|
app/routes/health.ts | GET /health (та будь-який експортований метод) |
app/routes/index.ts | GET / |
app/routes/api/posts.ts | /api/posts |
app/routes/posts/[id].ts | /posts/:id — ctx.params.id — рядок |
app/routes/posts/[id]/publish.ts | /posts/:id/publish |
app/routes/files/[...rest].ts | /files/* catch-all — ctx.params.rest — решта шляху |
Усі path-параметри приходять як рядки. Конвертуйте самі (Number(ctx.params.id)) або валідуйте схемою моделі.
Кілька методів в одному файлі
Експортуйте одну функцію на HTTP-метод:
// app/routes/posts/[id].ts
import { defineRoute } from '@hopak/core';
export const GET = defineRoute({ handler: (ctx) => ({ id: ctx.params.id }) });
export const POST = defineRoute({ handler: async (ctx) => ({ created: await ctx.body() }) });
export const DELETE = defineRoute({ handler: (ctx) => ({ deleted: ctx.params.id }) });
Неекспортований метод автоматично повертає 405 Method Not Allowed. Експорт default трактується як GET.
Читання запиту
Всередині handler-а все потрібне лежить на ctx:
ctx.params.id // string — path param
ctx.query.get('tag') // URLSearchParams — ?tag=foo
ctx.headers.get('authorization')
await ctx.body() // parsed JSON (cached — safe to call twice)
await ctx.text() // raw body (also cached)
ctx.ip // client IP or undefined
Повертайте будь-що — простий об’єкт, рядок, Response, null — фреймворк серіалізує. Повна поверхня описана в Request context.
5. Кастомізувати один CRUD-endpoint
Мета: замінити лише handler POST /api/posts власною логікою, інші п’ять ендпоїнтів залишити як є.
CRUD-файли — це звичайний код. Відкрийте app/routes/api/posts.ts і замініть експорт POST власним defineRoute(...):
// app/routes/api/posts.ts
import { crud, defineRoute, ValidationError } from '@hopak/core';
import post from '../../models/post';
export const GET = crud.list(post);
export const POST = defineRoute({
handler: async (ctx) => {
const body = (await ctx.body()) as { title?: string };
if (!body.title?.startsWith('[DRAFT]')) {
throw new ValidationError('Title must start with [DRAFT]');
}
return ctx.db!.model('post').create({
title: body.title,
content: 'auto-generated draft',
});
},
});
GET /api/posts усе ще приходить з crud.list; файл рівня елемента (posts/[id].ts) не чіпаємо.
Вимкнути окремий verb
Видаліть експорт цього verb-а. Router відповість 405 Method Not Allowed з хедером Allow:, який перелічує verb-и, що лишилися.
// app/routes/api/posts/[id].ts — DELETE removed
import { crud } from '@hopak/core';
import post from '../../../models/post';
export const GET = crud.read(post);
export const PUT = crud.update(post);
export const PATCH = crud.patch(post);
// no DELETE — clients see 405 with Allow: GET, PUT, PATCH
Повністю пропустити CRUD для цієї моделі
Не запускайте для неї hopak generate crud або видаліть два згенеровані файли. Модель усе одно стає таблицею, і до неї можна звертатися через ctx.db!.model('post') з будь-якого власного маршруту.
6. Кинути типізовану помилку
Мета: зупинити обробку і повернути правильний HTTP-статус з JSON-тілом.
Імпортуйте підклас HopakError і киньте його будь-де — фреймворк серіалізує за вас.
// app/routes/posts/[id]/claim.ts
import { defineRoute, NotFound, Forbidden } from '@hopak/core';
export const POST = defineRoute({
handler: async (ctx) => {
const id = Number(ctx.params.id);
const post = await ctx.db?.model('post').findOne(id);
if (!post) throw new NotFound(`Post ${id} not found`);
if (post.published) throw new Forbidden('Published posts cannot be claimed');
// ... rest of the logic
return { ok: true };
},
});
NotFound дає:
HTTP/1.1 404 Not Found
Content-Type: application/json
{ "error": "NOT_FOUND", "message": "Post 42 not found" }
Вбудовані підкласи
| Клас | Статус | Код error |
|---|---|---|
ValidationError | 400 | VALIDATION_ERROR |
Unauthorized | 401 | UNAUTHORIZED |
Forbidden | 403 | FORBIDDEN |
NotFound | 404 | NOT_FOUND |
Conflict | 409 | CONFLICT |
RateLimited | 429 | RATE_LIMITED |
InternalError | 500 | INTERNAL_ERROR |
ConfigError | 500 | CONFIG_ERROR |
Кожен підклас приймає необов’язковий другий аргумент details, який рендериться під "details":
throw new Unauthorized('Invalid token', { reason: 'expired' });
// 401 { "error": "UNAUTHORIZED", "message": "Invalid token",
// "details": { "reason": "expired" } }
Невідомі помилки
Усе, що не є HopakError (звичайний Error, відхилений promise, кинутий рядок), перетворюється на:
HTTP/1.1 500 Internal Server Error
{ "error": "INTERNAL_ERROR", "message": "Internal server error" }
Оригінальна помилка логується через ctx.log.error(...) — нічого про причину не витікає клієнту. Встановіть server.exposeStack: true у dev, щоб включити stack trace у тіло відповіді (зручно при відлагодженні; ніколи не вмикайте у продакшені).
7. Оголосити власну помилку
Мета: ввести доменно-специфічну помилку, як-от PaymentFailed (402).
Створіть підклас HopakError і перевизначте status та code. Обидва поля — readonly, тож оголошуйте їх з override readonly:
// app/lib/errors.ts
import { HopakError } from '@hopak/core';
export class PaymentFailed extends HopakError {
override readonly status = 402;
override readonly code = 'PAYMENT_FAILED';
}
export class QuotaExceeded extends HopakError {
override readonly status = 429;
override readonly code = 'QUOTA_EXCEEDED';
}
Використовуйте з будь-якого handler-а:
import { PaymentFailed } from '../lib/errors';
throw new PaymentFailed('Insufficient funds', { available: 5, required: 20 });
Відповідь:
HTTP/1.1 402 Payment Required
Content-Type: application/json
{ "error": "PAYMENT_FAILED", "message": "Insufficient funds",
"details": { "available": 5, "required": 20 } }
Де їх розміщувати
Підходить будь-який шлях у проєкті — класи помилок це звичайний TypeScript, їх не підхоплює сканер. Типові шаблони:
app/lib/errors.ts— один спільний файлapp/models/<domain>/errors.ts— поруч із фічею, яка їх кидає
Просто import та throw. Ніякого кроку реєстрації.
details — довільної форми
Конструктор приймає будь-що серіалізовуване як другий аргумент. Рендериться дослівно під "details", тож обирайте форму, корисну для клієнта:
throw new QuotaExceeded('Monthly quota exceeded', {
limit: 1000,
used: 1000,
resetsAt: '2026-05-01T00:00:00Z',
});
8. Запит до БД усередині handler-а
Мета: читати/писати рядки з власного маршруту тим самим типізованим клієнтом, який використовує CRUD.
ctx.db.model('<name>') повертає клієнт з повним CRUD, фільтрами, сортуванням і пагінацією.
// app/routes/posts-by-author/[userId].ts
import { defineRoute } from '@hopak/core';
export const GET = defineRoute({
handler: async (ctx) => {
const userId = Number(ctx.params.userId);
const posts = await ctx.db?.model('post').findMany({
where: { author: userId, published: true },
orderBy: [{ field: 'id', direction: 'desc' }],
limit: 20,
});
const total = await ctx.db?.model('post').count({ where: { author: userId } });
return { posts, total };
},
});
Повна поверхня клієнта
client.findMany({ where?, orderBy?, limit?, offset? });
client.findOne(id); // TRow | null
client.findOrFail(id); // throws NotFound(`<model>:<id>`)
client.count({ where? });
client.create(data); // returns the inserted row, id included
client.update(id, data); // partial update — throws NotFound if the row is gone
client.delete(id); // boolean — false if it didn't exist
Усі методи повністю типізовані з моделі — data має відповідати формі поля, row.title — це string, тощо.
Фільтри — що підтримує where
Прості значення означають рівність (where: { published: true }). Для порівнянь, пошуку підрядка, IN, BETWEEN, OR, NOT — див. Recipe 10. Повний довідник операторів там.
Значення пагінації за замовчуванням
client.findMany({}) // no limit, no offset
client.findMany({ limit: 20 }) // LIMIT 20
client.findMany({ limit: 20, offset: 40 })
Типізований клієнт передає limit як є. CRUD-ендпоїнти (через HTTP) накладають обмеження 100 на query-параметр ?limit=, щоб публічний трафік не просив мільйони рядків; прямі виклики клієнта всередині Ваших handler-ів такого обмеження не мають.
Сирий SQL через db.sql
Усе, що типізований клієнт не вміє, робите через db.sql — tagged template, який параметризує інтерполяції автоматично й повертає типізовані рядки:
import { defineRoute } from '@hopak/core';
export const GET = defineRoute({
handler: async (ctx) => {
const rows = await ctx.db!.sql<{ author: number; n: number }>`
SELECT author, COUNT(*) AS n FROM post
GROUP BY author
ORDER BY n DESC
LIMIT 10
`;
return { topAuthors: rows };
},
});
Інтерполяції ${value} стають driver-native placeholder-ами (? на SQLite / MySQL, $N на Postgres). Значення ніколи не потрапляють у текст SQL — безпечно з user input.
Для <1% випадків, де db.sql замало (композиція query-білдера, Drizzle-плагіни), падайте далі: ctx.db!.builder() повертає нативний Drizzle-екземпляр діалекту. Повна ієрархія escape — у документації database.
ctx.db — undefined, коли немає моделей
Якщо в проєкті нуль моделей, Hopak не відкриває базу — ctx.db лишається undefined. Handler-и, що її вимагають, перевіряють явно:
if (!ctx.db) throw new InternalError('Database not configured');
const posts = await ctx.db.model('post').findMany();
Нормальний застосунок з хоча б однією моделлю в app/models/ завжди має ctx.db.
9. Зв’язки між моделями
Мета: один автор має багато постів; кожен пост належить одному автору.
У Hopak два типи relation-полів:
| Поле | Створює колонку? | Значення |
|---|---|---|
belongsTo('user') | так — user_id (integer FK) | цей рядок вказує на батька |
hasOne('profile') / hasMany('post') | ні — віртуальне | підказка для тулінгу, без впливу на схему |
// app/models/user.ts
import { model, text, email, hasMany } from '@hopak/core';
export default model('user', {
name: text().required(),
email: email().required().unique(),
posts: hasMany('post'), // virtual — no column
});
// app/models/post.ts
import { model, text, belongsTo } from '@hopak/core';
export default model('post', {
title: text().required(),
author: belongsTo('user'), // creates `author_id` foreign key
});
Створюємо рядки:
curl -X POST http://localhost:3000/api/users \
-H 'content-type: application/json' -d '{"name":"Alice","email":"a@b.com"}'
# → { "id": 1, ... }
curl -X POST http://localhost:3000/api/posts \
-H 'content-type: application/json' -d '{"title":"Hi","author":1}'
# → { "id": 1, "title": "Hi", "author": 1, ... }
FK-поле в API
Поле API — це те, як ви назвали його в моделі (author). Ім’я колонки під капотом — <field>_id, але ви з ним не взаємодієте — Hopak мапить в обидва боки. Надсилайте { "author": 1 } у JSON; фільтруйте через { where: { author: 1 } } у клієнті; отримуйте "author": 1 у відповідях.
Цілісність foreign-key забезпечує SQLite — вставка посту з author: 999, якщо користувача 999 немає, повертає 409 CONFLICT.
Жадібне завантаження relation-ів
Hopak батчить вибірку relation-ів в один запит WHERE id IN (...) — без N+1. Див. Recipe 11 для повного API:
await ctx.db.model('post').findMany({ include: { author: true } });
await ctx.db.model('user').findMany({ include: { posts: true, profile: true } });
Міграції
hopak sync (і перший запуск hopak dev) створює колонку та FK-обмеження через CREATE TABLE IF NOT EXISTS — ідемпотентний повтор, без ALTER TABLE. Зміна цілі belongsTo на наявній таблиці не змінює дані; для прототипування видаліть .hopak/data.db (або дропніть таблицю на Postgres / MySQL) і синхронізуйте ще раз.
10. Фільтри з операторами — gte, like, in, between, OR, NOT
Мета: будувати реальні findMany-запити — діапазони, пошук підрядка, OR-гілки, перевірки на null — без SQL.
Кожен фільтр живе під where. Літеральне значення означає рівність. Об’єкт з одним із operator-ключів перемикає на відповідне порівняння:
await ctx.db.model('post').findMany({
where: {
published: true, // equality (unchanged)
views: { gte: 100 }, // >= 100
title: { contains: 'hopak' }, // substring, case-insensitive
createdAt: { between: [start, end] }, // inclusive range
author: { in: [1, 2, 3] }, // IN (1, 2, 3)
OR: [{ featured: true }, { score: { gt: 50 } }],
NOT: { archived: true },
},
orderBy: [{ field: 'views', direction: 'desc' }],
limit: 20,
});
Довідник операторів
| Оператор | SQL | Примітки |
|---|---|---|
eq, neq | =, != | Рівність (явно); eq — дефолт для літеральних значень |
gt, gte, lt, lte | >, >=, <, <= | Числові / дата-порівняння |
in, notIn | IN (...), NOT IN (...) | Масив значень |
between | BETWEEN x AND y | Включний діапазон — [min, max] |
contains | LIKE '%x%' | Пошук підрядка, wildcards автоекрануються |
startsWith | LIKE 'x%' | Префіксний пошук |
endsWith | LIKE '%x' | Суфіксний пошук |
like | LIKE 'x' | Сирий патерн — % і _ контролюєте самі |
ilike | ILIKE 'x' (PG) / LIKE (SQLite+MySQL, case-insensitive за замовчуванням) | Case-insensitive підрядок/патерн |
isNull, isNotNull | IS NULL, IS NOT NULL | Передайте true як значення |
Комбінування умов
| Ключ | Поведінка |
|---|---|
| Поля верхнього рівня | Неявний AND по всіх ключах |
AND: [...] | Явний AND — зручно для комбінації заздалегідь побудованих умов |
OR: [...] | Будь-яка з гілок підходить |
NOT: {...} | Заперечити підумову |
// Posts that are published AND (views >= 100 OR featured)
await posts.findMany({
where: {
published: true,
OR: [{ views: { gte: 100 } }, { featured: true }],
},
});
Нюанс: wildcards LIKE екрануються
Коли ви передаєте літеральні % або _ у contains / startsWith / endsWith, Hopak екранує їх за вас. contains: '100%' шукає літеральне “100%” у даних, а не “все, що закінчується на 100, а далі будь-що”. Для сирих LIKE-патернів, де wildcards контролюєте ви, використовуйте like: '...'.
11. Завантажити зв’язані записи через include — без N+1
Мета: отримати пости разом з авторами, або користувачів з їх постами — одним батчованим запитом на relation (а не по запиту на кожен основний рядок).
// app/models/user.ts
model('user', {
name: text().required(),
email: email().required().unique(),
posts: hasMany('post'),
profile: hasOne('profile'),
});
// app/models/post.ts
model('post', {
title: text().required(),
author: belongsTo('user'),
});
// app/models/profile.ts
model('profile', {
bio: text().required(),
owner: belongsTo('user'),
});
belongsTo — вибрати батька
const posts = await ctx.db.model('post').findMany({
include: { author: true },
});
// [{ id: 1, title: 'Hello', author: { id: 7, name: 'Alice', ... } }, ...]
Під капотом: один SELECT * FROM posts + один SELECT * FROM users WHERE id IN (<unique author ids>). Hopak індексує результат і дошиває його до кожного посту. Два запити загалом, незалежно від того, скільки постів ви вибрали.
hasMany — вибрати дітей, з фільтром та сортуванням
const users = await ctx.db.model('user').findMany({
include: {
posts: {
where: { published: true },
orderBy: [{ field: 'createdAt', direction: 'desc' }],
},
},
});
// [{ id: 1, name: 'Alice', posts: [{...}, {...}] }, ...]
Один запит для користувачів, один для SELECT * FROM posts WHERE author IN (<user ids>) AND published = true ORDER BY created_at DESC. Далі згруповано по FK і додано. Батьки без відповідних дітей отримують posts: [].
hasOne — вибрати єдину дитину (або null)
const users = await ctx.db.model('user').findMany({
include: { profile: true },
});
// [{ id: 1, ..., profile: { bio: 'Hi' } }, { id: 2, ..., profile: null }]
Кілька include-ів в одному виклику
await ctx.db.model('user').findMany({
include: {
posts: true,
profile: true,
comments: { orderBy: [{ field: 'createdAt', direction: 'desc' }], limit: 5 },
},
});
Усе ще без N+1: один запит для користувачів, по одному на relation. Три запити для цього прикладу, скільки б користувачів не повернулося.
12. Upsert та масові записи
Мета: “вставити-або-оновити одним викликом”, плюс createMany / updateMany / deleteMany для масових операцій.
Upsert
const user = await ctx.db.model('user').upsert({
where: { email: 'alice@example.com' }, // conflict target
create: { name: 'Alice', password: 'hash' },
update: { name: 'Alice Updated' },
});
- Якщо під
whereнемає рядка → вставляє{ ...where, ...create } - Якщо рядок знайдено → оновлює через
update, повертає свіжий рядок
Під капотом: ON CONFLICT (email) DO UPDATE на SQLite + Postgres, ON DUPLICATE KEY UPDATE на MySQL. Ключі where повинні відповідати UNIQUE-обмеженню або primary key, інакше конфлікт не спрацює.
Пакетні операції
Усі три повертають { count: number }:
const { count: created } = await posts.createMany([
{ title: 'a', content: 'x' },
{ title: 'b', content: 'y' },
{ title: 'c', content: 'z' },
]);
// created === 3
const { count: updated } = await posts.updateMany({
where: { published: false },
data: { published: true, reviewedAt: new Date() },
});
const { count: deleted } = await posts.deleteMany({
where: { views: { lt: 5 }, createdAt: { lt: thirtyDaysAgo } },
});
Нюанс: deleteMany({}) видаляє все
Порожній об’єкт where відповідає всім рядкам — навмисно, тож deleteMany({}) — явний escape hatch “truncate через ORM”. Якщо хочете бути певними, що фільтр присутній у рантаймі, перевіряйте самі перед викликом.
13. Агрегати — sum, avg, count (опціонально з groupBy)
Мета: рахувати статистику по рядках без SQL.
Однорядковий агрегат (по всіх відповідних рядках)
const result = await ctx.db.model('post').aggregate({
where: { published: true },
sum: ['views', 'likes'],
avg: ['rating'],
min: ['createdAt'],
max: ['createdAt'],
count: '_all', // total row count
});
// {
// sum: { views: 12400, likes: 356 },
// avg: { rating: 4.2 },
// min: { createdAt: 2024-03-01T... },
// max: { createdAt: 2026-04-20T... },
// count: { _all: 142 },
// }
count: ['field'] рахує не-null значення цієї колонки (корисно на nullable-полях). count: '_all' — це COUNT(*), кожен рядок.
Груповий агрегат — один рядок результату на окрему групу
const perAuthor = await posts.aggregate({
where: { published: true },
groupBy: ['author'],
sum: ['views'],
count: '_all',
});
// [
// { author: 1, sum: { views: 5400 }, count: { _all: 42 } },
// { author: 2, sum: { views: 1800 }, count: { _all: 15 } },
// ...
// ]
groupBy перемикає тип повернення на масив рядків результату. Кожен рядок містить значення колонок group-by плюс агрегати. Щоб сортувати чи пагінувати, підтягніть результат і робіть у JS — або використайте db.sql для серверного ORDER BY sum(views) DESC LIMIT 10.
14. Cursor-пагінація (keyset)
Мета: ефективно пагінувати великі таблиці — LIMIT/OFFSET сканує пропущені рядки, cursor-пагінація одразу стрибає на наступну сторінку за O(log n).
// Page 1
const page1 = await posts.findMany({
orderBy: [{ field: 'id', direction: 'asc' }],
limit: 20,
});
// Page 2 — pass the last id from page 1 as the cursor
const page2 = await posts.findMany({
cursor: { id: page1.at(-1)?.id },
orderBy: [{ field: 'id', direction: 'asc' }],
limit: 20,
});
// Page 3 — and so on
const page3 = await posts.findMany({
cursor: { id: page2.at(-1)?.id },
orderBy: [{ field: 'id', direction: 'asc' }],
limit: 20,
});
Колонка cursor має бути в orderBy — напрямок там вирішує, чи означає cursor “строго після” (asc) чи “строго перед” (desc).
Типова форма API для нескінченного скролу
// app/routes/api/posts/feed.ts
export const GET = defineRoute({
handler: async (ctx) => {
const cursor = ctx.query.get('cursor');
const posts = await ctx.db!.model('post').findMany({
cursor: cursor ? { id: Number(cursor) } : undefined,
orderBy: [{ field: 'id', direction: 'desc' }],
limit: 20,
});
return {
items: posts,
nextCursor: posts.length === 20 ? posts.at(-1)?.id : null,
};
},
});
Нюанси
- Лише однокомпонентні cursor-и. Багатоколонковий keyset (напр.
(createdAt, id)для стабільного сортування, колиcreatedAtповторюється) потребує синтаксису tuple-порівнянь, який відрізняється між діалектами. Для цього випадку сортуйте за стабільно-унікальною колонкою (наприклад,id) або переходьте наdb.sqlз композитним WHERE. - Значення cursor мають бути не-null. Передавання
cursor: { id: null }кидає помилку. - Ключ cursor має бути в
orderBy; інакше напрямок неоднозначний, і Hopak кидає з підказкою.
15. Транзакції та row-локи
Мета: атомарні мульти-записи плюс безпечні конкурентні оновлення через SELECT ... FOR UPDATE.
Базова транзакція — commit при resolve, rollback при throw
await ctx.db.transaction(async (tx) => {
const user = await tx.model('user').create({ name: 'Alice', email: 'a@b.c' });
await tx.model('profile').create({ bio: 'hi', owner: user.id });
});
// Both rows persisted atomically.
// If the second create fails, the first is rolled back.
Аргумент tx — це скопоупований Database — той самий API, що й ctx.db, але кожен виклик tx.model(...) бере участь у транзакції. Зовнішні запити на ctx.db не в транзакції.
Rollback поширюється з будь-якого throw
await ctx.db.transaction(async (tx) => {
await tx.model('account').update(fromId, { balance: fromBalance - 100 });
await tx.model('account').update(toId, { balance: toBalance + 100 });
if (!await checkFraud(tx, toId)) {
throw new Forbidden('suspicious transfer');
}
});
// If `checkFraud` throws, both updates are rolled back.
// The thrown error still reaches the caller.
Типізовані підкласи HopakError (NotFound, Forbidden, тощо) працюють так само — обробник помилок фреймворку форматує відповідь, транзакція відкочується чисто.
Песимістичне row-блокування — SELECT ... FOR UPDATE
Для шаблонів “читай-потім-зміни” під конкурентністю (лічильники, баланси, черги) читання з lock: 'forUpdate' бере ексклюзивний лок на рядок до commit або rollback транзакції. Друга конкурентна транзакція з тим самим читанням чекає на першу.
await ctx.db.transaction(async (tx) => {
const account = await tx.model('account').findOrFail(id, { lock: 'forUpdate' });
await tx.model('account').update(id, { balance: account.balance + amount });
});
Без локу дві конкурентні інкрементації змагаються, і одна губиться. З локом вони серіалізуються: друга чекає, читає закомічене нове значення, додає зверху.
| Діалект | Поведінка |
|---|---|
| Postgres | Нативний SELECT ... FOR UPDATE |
| MySQL | Нативний SELECT ... FOR UPDATE |
| SQLite | Тихий no-op — транзакції SQLite уже однописні (ексклюзивні) |
lock: 'forShare' — слабший варіант — shared-лок, декілька читачів OK, блокує писачів. Використовується для “я читаю цей рядок і не хочу, щоб він змінювався, поки я вирішую, що робити”.
Застереження
- Без вкладених транзакцій. Виклик
tx.transaction(...)усередині транзакції кидає. Для часткового rollback у межах транзакції використовуйте SAVEPOINT-и черезtx.builder(). lockпідтримується наfindOne/findOrFail/findMany. Лок діє лише на основні рядки —includeвиконує окремий, незалокований запит для relation-ів.
16. Вибрати конкретні колонки — select, distinct
Мета: повертати тільки ті колонки, які потрібні клієнту (select), та дедуплікувати рядки (distinct).
select — проєкція колонок
const rows = await ctx.db.model('post').findMany({
select: ['id', 'title'],
where: { published: true },
});
// [{ id: 1, title: 'Hello' }, ...] — no `content`, no `views`, nothing else
Типізовано як Pick<TRow, 'id' | 'title'>[]. Корисно, коли:
- Рядки містять важкі поля (
content: text(),json<T>(), blob-подібні дані), а вам потрібен лише список - Ви хочете, щоб формат по дроту відповідав конкретному контракту клієнта
- Ви робите join через
includeі не хочете важких колонок базової таблиці
select + include добре дружать
const articles = await ctx.db.model('article').findMany({
select: ['id', 'title'],
include: { author: true },
});
// [{ id: 1, title: '...', author: { id: 7, name: 'Alice', email: '...' } }]
Навіть якщо ви попросили лише id та title, Hopak прозоро підтягує FK-колонку, потрібну для include, а потім замінює її на вкладений об’єкт автора перед поверненням.
distinct: true — дедуплікація на всіх діалектах
const titles = await posts.findMany({
select: ['title'],
distinct: true,
orderBy: [{ field: 'title', direction: 'asc' }],
});
distinct: ['col'] — Postgres DISTINCT ON
У Postgres є SELECT DISTINCT ON (col) — “один рядок на кожне унікальне значення col, ORDER BY вирішує, який перемагає”. Корисно для запитів “останній пост кожного автора”:
// Postgres only
await posts.findMany({
distinct: ['author'],
orderBy: [
{ field: 'author', direction: 'asc' }, // must come first
{ field: 'createdAt', direction: 'desc' },
],
});
На SQLite / MySQL це кидає з підказкою — це SQL-розширення нестандартне, і чистого портативного перепису немає. Використовуйте db.sql або підзапит, якщо потрібна та сама семантика кросдіалектно.
17. Вибрати або змінити базу даних
Мета: використовувати Postgres або MySQL замість дефолтного SQLite.
Є дві точки входу, залежно від того, де ви зараз.
17a. На створенні проєкту — hopak new --db postgres
Оберіть діалект наперед; hopak new підключає все за один прохід.
hopak new my-app --db postgres
cd my-app
Що відбувається:
hopak.config.tsпишеться зdatabase: { dialect: 'postgres', url: process.env.DATABASE_URL }.package.jsonміститьpostgres(абоmysql2для MySQL) як залежність —bun installпідбирає її в тому ж проходіhopak new..env.exampleмістить плейсхолдер:DATABASE_URL=postgres://user:pass@localhost:5432/myapp.README.mdу проєкті каже про додатковий крок налаштування (“скопіюйте.env.example→.env, виконайтеhopak sync”).
Далі:
cp .env.example .env # fill in real credentials
hopak sync # CREATE TABLE IF NOT EXISTS for every model
hopak dev # boots on port 3000
17b. У наявному проєкті — hopak use postgres
Перемкнути вже згенерований проєкт з одного діалекту на інший.
hopak use postgres
Що це робить:
- Ставить драйвер —
bun add postgres(абоbun add mysql2). SQLite поставляється з Bun — ставити нічого. - Переписує блок
database:вhopak.config.ts. Патчер впізнає голий дефолт зhopak newі заміняє його чисто; налаштований блок (власний шлях до sqlite-файлу, додаткові параметри URL, конфігsslтощо) лишається недоторканим, а команда друкує сніпет, який треба вставити вручну, щоб Ваше налаштування не було мовчки викинуто. - Додає
DATABASE_URLдо.env.example, якщо його ще немає.
Далі:
# 1. Start Postgres locally (or use a managed one like Neon / Supabase / RDS)
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=hopak postgres:16-alpine
# 2. Fill DATABASE_URL in .env:
# DATABASE_URL=postgres://postgres:hopak@localhost:5432/postgres
# 3. Sync schema + run
hopak sync
hopak dev
Решта коду проєкту не змінюється — моделі, CRUD-маршрути, ctx.db.model(...) — все працює ідентично на кожному діалекті.
Відмінності діалектів (короткий огляд)
| Річ | SQLite | Postgres | MySQL |
|---|---|---|---|
| Встановлення | постачається з Bun | hopak use postgres | hopak use mysql |
| Пакет драйвера | bun:sqlite | postgres (postgres.js) | mysql2 |
ilike | LIKE (case-insensitive ASCII) | нативний ILIKE | LIKE (case-insensitive collation) |
distinct: ['col'] | кидає | DISTINCT ON | кидає |
lock: 'forUpdate' | тихий no-op (послідовні writes уже) | нативний FOR UPDATE | нативний FOR UPDATE |
| Unique на TEXT | інлайновий UNIQUE | інлайновий UNIQUE | окремий UNIQUE KEY (col(191)) — обробляється всередині |
| Емітиться FK-обмеження | пропускається | так | так |
Усе позначене як “кидає” або “обробляється всередині” стосується того, як фіча емітиться, а не того, чи мусить змінюватися Ваш код — ви все одно пишете .unique() або lock: 'forUpdate' у тому ж місці.
18. Увімкнути HTTPS у локальному dev
Мета: тестувати фронтенд проти https://localhost:3443 із самопідписаним сертом.
1. Згенеруйте dev-серт:
hopak generate cert
# → Generating self-signed dev certificate { path: ".hopak/certs" }
# → Dev certificate ready. Re-run `hopak dev` with HTTPS enabled.
Це запускає openssl req -x509 один раз і пише два файли плюс .gitignore, який тримає їх поза системою контролю версій:
.hopak/certs/
├── dev.key # private key (gitignored)
├── dev.crt # self-signed cert (gitignored)
└── .gitignore # `*` — ignores everything except itself
Потрібен openssl на машині. macOS постачає його. На Ubuntu/Debian: apt install openssl. На Alpine: apk add openssl.
2. Увімкніть HTTPS у конфігу:
// hopak.config.ts
import { defineConfig } from '@hopak/core';
export default defineConfig({
server: { https: { enabled: true, port: 3443 } },
});
3. Перезапустіть dev-сервер:
hopak dev
Hopak читає пару сертів з .hopak/certs/dev.{key,crt} і обслуговує як HTTP (порт 3000), так і HTTPS (порт 3443). Якщо файлів немає, він швидко падає з підказкою про hopak generate cert — нічого не синтезується за Вашою спиною при старті.
4. Перевірте:
curl -k https://localhost:3443/ # -k accepts the self-signed cert
Браузер покаже попередження вперше — це очікувано для самопідписаного серта. Видаліть .hopak/certs/ і перезапустіть hopak generate cert, щоб перевидати.
Довірити серт (прибрати попередження браузера)
Якщо попередження блокує Ваш фронтенд (напр. cookie з SameSite=None; Secure не встановлюється), довіртеся до серта один раз:
macOS:
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain .hopak/certs/dev.crt
Linux (Debian/Ubuntu):
sudo cp .hopak/certs/dev.crt /usr/local/share/ca-certificates/hopak-dev.crt
sudo update-ca-certificates
Перезапустіть браузер, щоб він підхопив нове trust-сховище.
Продакшен-сертифікати
Надайте реальні шляхи до серта й ключа. Обидва файли мають бути читабельні користувачем, під яким працює Hopak:
// hopak.config.ts
server: {
https: {
enabled: true,
port: 443,
cert: '/etc/ssl/myapp.crt',
key: '/etc/ssl/myapp.key',
},
}
Порт 443 потребує root на Linux/macOS. Дві типові схеми:
- Reverse proxy (рекомендовано): хай Nginx/Caddy/Cloudflare термінує TLS і проксить простий HTTP на Hopak на
:3000. Тримайтеhttps.enabled: falseз боку застосунку. - Capabilities / setcap:
sudo setcap cap_net_bind_service=+ep $(which bun)дає non-root користувачу прив’язуватися до 443 напряму.
Права на файли мають значення — файл ключа має бути chmod 600 і належати користувачу застосунку, ніколи не доступний всьому світу.
HTTP і HTTPS одночасно
Сьогодні не підтримується. https.enabled: true заміщає HTTP-лісенер; https.port — єдиний порт. Якщо треба обидва, запускайте за reverse proxy, що обробляє редирект 80 → 443 (Caddy робить це за замовчуванням).
Порти — що де використовується
| Конфіг | Dev |
|---|---|
server.port | HTTP-лісенер |
server.https.enabled: true + https.port | HTTPS-лісенер; HTTP-лісенер не стартує |
Без https.port | Повертається до server.port; якщо це 3000, HTTPS біндиться на 3000 |
Встановлюйте https.port: 3443 явно в dev, щоб фронтенд міг продовжувати використовувати :3000 для звичайного HTTP, поки ви тестуєте TLS.
19. Дозволити CORS з Вашого фронтенду
Мета: дозволити фронтенду Vite/Next на http://localhost:5173 звертатися до Вашого API з кукі.
CORS вимкнений за замовчуванням — cross-origin запити з браузера не отримують CORS-хедерів і падають. Увімкніть для конкретних origin-ів:
// hopak.config.ts
import { defineConfig } from '@hopak/core';
export default defineConfig({
cors: {
origins: ['http://localhost:5173', 'https://myapp.com'],
credentials: true,
},
});
Preflight (OPTIONS) обробляється автоматично шаром CORS — Ваші handler-и його не бачать.
Публічні API
Wildcard — без кукі:
cors: { origins: '*' }
Нюанс: * + credentials: true — браузер відхилить
Специфікація CORS забороняє Access-Control-Allow-Origin: * разом з Access-Control-Allow-Credentials: true. Браузери відмовляться від відповіді, навіть якщо Hopak надішле обидва — fetch падає з загальним “CORS error” у devtools.
Якщо потрібні кукі, перелічіть точні origin-и:
cors: {
origins: ['http://localhost:5173', 'https://app.myapp.com'],
credentials: true,
}
Рядок origin має збігатися точно
'http://localhost:5173' не те саме, що:
'localhost:5173'(без схеми)'http://localhost:5173/'(з косою рискою в кінці)'http://127.0.0.1:5173'(інший хост)
Браузери надсилають хедер Origin точно як origin сторінки. Якщо сумніваєтеся — скопіюйте з вкладки Network у devtools.
Чекліст відлагодження
Коли браузер блокує виклик, пройдіться так:
- Відкрийте devtools → Network → запит, який падає → Headers. Чи присутній request-хедер
Origin? - Перевірте відповідь. Чи є в ній
Access-Control-Allow-Origin? Якщо немає → сервер не розпізнав origin (друкарська помилка вorigins). Якщо є, але не той → проблема точного збігу (коса в кінці, розбіжність схеми). - Це preflight? Запити з
content-type: application/json+ credentials тригерять preflightOPTIONSспочатку. Перевірте, щоOPTIONSповертає204з правильними хедерами. Якщо404— маршруту дляOPTIONSнемає — Hopak обробляє preflight, лише колиcorsсконфігуровано, тож переконайтеся, щоhopak.config.tsдійсно завантажено (hopak checkйого друкує). - Credentialed-запит? Клієнт має надсилати
fetch(url, { credentials: 'include' })І сервер має матиcredentials: true, І origin-и мають бути явними (не*). Усі три, інакше кукі не підуть. - Перезапустіть сервер.
hopak devпідхоплює зміни конфігу при збереженні файлу, але якщо не впевнені, Ctrl-C і перезапустіть — помилки завантаження конфігу друкуються в stdout.
Той самий origin? CORS не потрібен
Якщо фронтенд і бекенд обслуговуються з того самого origin (напр., обидва з https://myapp.com), браузер не надсилає Origin, і CORS не застосовується. Лишіть блок cors поза конфігом.
20. Обслуговувати статичні файли
Мета: віддавати favicon.ico, зображення, зібраний SPA або будь-який інший файл прямо з диска.
Базово — кладіть файли у public/
За замовчуванням Hopak віддає все всередині public/ за кореневим URL. Без коду, без конфігу.
public/
├── index.html → GET / (fallback when no route matches)
├── favicon.ico → GET /favicon.ico
├── robots.txt → GET /robots.txt
└── assets/
├── logo.svg → GET /assets/logo.svg
└── app.js → GET /assets/app.js
curl -i http://localhost:3000/favicon.ico
HTTP/1.1 200 OK
Content-Type: image/x-icon
Content-Length: 4286
Cache-Control: public, max-age=300
ETag: W/"10be-19d9b9abe40.cc"
Last-Modified: Mon, 17 Apr 2026 10:00:00 GMT
Content-Type визначається автоматично за розширенням файлу.
Використати іншу директорію
Віддавати з static/ або web/dist/ (напр. вихід Vite-білда) — налаштуйте paths.public:
// hopak.config.ts
import { defineConfig } from '@hopak/core';
export default defineConfig({
paths: { public: 'web/dist' }, // relative to project root
});
Абсолютні шляхи теж працюють:
paths: { public: '/var/www/myapp' }
Перезапустіть сервер — директорія резолвиться один раз при старті.
Повністю вимкнути статику
Якщо Ваш API має нічого не повертати на невідомі URL (без пошуку у public/), націльте paths.public на директорію, якої немає. Відсутня директорія — no-op — запити провалюються прямо на 404:
paths: { public: '.hopak/nothing' }
Без помилки при старті — Hopak іде на файлову систему лише на реальних GET-запитах.
Пріоритет маршрутів
Для кожного вхідного запиту Hopak перевіряє в такому порядку:
- Файлові маршрути в
app/routes/(будь-який HTTP-метод) - CRUD-маршрути, згенеровані
model(...) - Статичний файл у
public/(лишеGETіHEAD) - 404 Not Found з JSON-тілом
Тож якщо у вас є і app/routes/index.ts, і public/index.html, відвідування / виконує handler маршруту — HTML ігнорується. Це корисно: тримайте JSON API на /, тримайте маркетингову сторінку на /landing.html, назвавши статичний файл інакше.
SPA fallback (клієнтський роутинг)
SPA на Vue/React/Svelte потребує, щоб /any/unknown/path віддавав index.html, щоб клієнтський router обробив його. Додайте catch-all маршрут, який читає й повертає entry point SPA:
// app/routes/[...rest].ts
import { defineRoute } from '@hopak/core';
const spa = await Bun.file('./public/index.html').text();
export const GET = defineRoute({
handler: (ctx) => {
// Only fall back for HTML navigation — let /api/* and static assets 404 normally
if (ctx.path.startsWith('/api/')) return new Response('Not Found', { status: 404 });
return new Response(spa, { headers: { 'Content-Type': 'text/html' } });
},
});
Тепер /assets/app.js усе ще приходить з public/ (файловий маршрут має нижчу специфічність, ніж статика для наявних файлів? — насправді ні: цей catch-all перемагає статику, тож маршрут виконується для кожного URL). Guard startsWith('/api/') — звичайний шаблон, щоб 404 API проходили.
Для строгого поділу обслуговуйте SPA з іншого mount, кладучи білд у public/app/ і використовуючи app/routes/app/[...rest].ts — тільки /app/* потрапляє у fallback.
Cache-хедери
Дефолти консервативні й наразі не конфігуруються:
| Хедер | Значення |
|---|---|
Cache-Control | public, max-age=300 |
ETag | weak ETag з size + mtime |
Last-Modified | mtime файлу |
Якщо потрібне довгострокове кешування для fingerprinted-ассетів (напр. app.abc123.js), обслуговуйте їх з CDN у продакшені або напишіть невеликий власний маршрут, що читає файл і ставить свої хедери:
// app/routes/assets/[...path].ts
import { defineRoute } from '@hopak/core';
import { file as bunFile } from 'bun';
import { resolve } from 'node:path';
export const GET = defineRoute({
handler: async (ctx) => {
const f = bunFile(resolve('./public/assets', ctx.params.rest));
if (!(await f.exists())) return new Response(null, { status: 404 });
return new Response(f, {
headers: {
'Content-Type': f.type,
'Cache-Control': 'public, max-age=31536000, immutable', // 1 year
},
});
},
});
Безпека
- Path traversal заблоковано. Запити до
/../../etc/passwdповертають 404 — зарезолвлений шлях має лежати всередині сконфігурованоїpublic-директорії. - Dotfiles віддаються.
public/.envбув би читабельний як/.env. Не кладіть секрети вpublic/. - Лише GET і HEAD потрапляють у статичний шар. POST/PUT/DELETE на статичний шлях повертають 404 (щоб не витікав method-not-allowed).
Коли взагалі пропустити статику
У продакшені статичні ассети зазвичай живуть на CDN (Cloudflare, S3 + CloudFront, Vercel Edge). Сконфігуруйте білд заливати web/dist/ на CDN, націльте клієнта на https://cdn.yourapp.com/, а public/ лишіть порожнім в образі сервера — статичний шар Hopak нічого не коштує, коли директорія порожня.
21. Перемістити вихідний код в іншу директорію
Мета: використовувати src/domain/ та src/api/ замість app/models/ та app/routes/.
// hopak.config.ts
import { defineConfig } from '@hopak/core';
export default defineConfig({
paths: {
models: 'src/domain',
routes: 'src/api',
migrations: 'src/migrations',
public: 'static',
},
});
Після цього:
hopak generate model postпише вsrc/domain/post.tshopak generate route posts/[id]пише вsrc/api/posts/[id].tshopak migrate new add_slugпише вsrc/migrations/<timestamp>_add_slug.tshopak dev,hopak sync,hopak checkсканують нові директорії- Статичні файли обслуговуються з
static/замістьpublic/
Усі конфігуровані шляхи
paths: {
models: 'src/domain', // where hopak scans models
routes: 'src/api', // where hopak scans routes
migrations: 'src/migrations',// where hopak migrate writes files
public: 'static', // static-file root
hopakDir: '.cache/hopak', // runtime data (SQLite file, certs). Default: .hopak
}
Усі шляхи резолвляться відносно кореня проєкту (де лежить hopak.config.ts). Абсолютні шляхи теж працюють — корисно при монтуванні спільного volume у Docker:
paths: { public: '/app/static' }
Міграція наявного проєкту
- Перенесіть файли:
mv app/models src/domain,mv app/routes src/api. - Додайте
pathsуhopak.config.ts. - Запустіть
hopak check— він друкує, що просканував, і підтверджує збіг кількості моделей/маршрутів. Якщо щось не так, check виходить зі статусом1(відмінно для CI). - Перезапустіть
hopak dev.
Жодних змін коду всередині файлів моделей/маршрутів — шляхи в hopak.config.ts — єдине джерело істини.
Нюанс: runtime-директорія .hopak/
.hopak/ тримає SQLite-файл (.hopak/data.db) та dev TLS-серти (.hopak/certs/). Перевизначте через paths.hopakDir, якщо треба інше розташування — напр. /var/lib/myapp у systemd-деплої. Директорія створюється автоматично при першому записі.
22. Генерація файлів через CLI
Мета: не писати boilerplate вручну. Кожен файл, який Hopak використовує для обслуговування Вашого застосунку, генерується однією командою і далі редагується як звичайний вихідний код — ніяка рантайм-магія не збирає маршрути, серти чи CRUD-handler-и за Вашою спиною.
Чотири види: model, route, crud, cert.
generate model <name> — одна таблиця
hopak generate model comment
# → Created file app/models/comment.ts
hopak g model comment # same thing, short form
Згенерована модель навмисно мінімальна; замініть поля на реальну схему:
// app/models/comment.ts
import { model, text } from '@hopak/core';
export default model('comment', {
name: text().required(),
});
Генерація лише моделі дає вам таблицю в БД (після hopak sync) та типізований клієнт (ctx.db.model('comment')) — але без HTTP-ендпоїнтів. Запустіть наступним hopak generate crud comment, щоб виставити REST, або напишіть свої файли маршрутів.
generate crud <name> — REST для моделі
hopak generate crud post
# → Created file app/routes/api/posts.ts
# → Created file app/routes/api/posts/[id].ts
Два файли, що використовують crud-хелпери з @hopak/core:
// app/routes/api/posts.ts
import { crud } from '@hopak/core';
import post from '../../models/post';
export const GET = crud.list(post);
export const POST = crud.create(post);
// app/routes/api/posts/[id].ts
import { crud } from '@hopak/core';
import post from '../../../models/post';
export const GET = crud.read(post);
export const PUT = crud.update(post);
export const PATCH = crud.patch(post);
export const DELETE = crud.remove(post);
Після скаффолду:
hopak check
# → Models 1 loaded (post)
# → Routes 6 file route(s)
hopak dev
# POST, GET list, GET /:id, PUT, PATCH, DELETE — all live on /api/posts
Кастомізуйте будь-який verb, замінивши відповідний експорт власним defineRoute(...); видаліть експорт, щоб прибрати verb повністю (router відповість 405 Method Not Allowed з хедером Allow:, що перелічує те, що лишилося). Див. Recipe 5 для повного сценарію.
Модель має існувати до запуску generate crud; команда пише лише файли маршрутів.
generate route <path> — один handler
hopak generate route search
# → Created file app/routes/search.ts (URL: /search)
hopak generate route posts/[id]/publish
# → Created file app/routes/posts/[id]/publish.ts (URL: /posts/:id/publish)
hopak generate route api/users/[id]
# → Created file app/routes/api/users/[id].ts (URL: /api/users/:id)
hopak generate route files/[...rest]
# → Created file app/routes/files/[...rest].ts (URL: /files/* catch-all)
Стартовий вміст:
import { defineRoute } from '@hopak/core';
export const GET = defineRoute({
handler: (ctx) => ({ ok: true, path: ctx.path }),
});
Перейменуйте GET на будь-який інший verb або додайте кілька експортів у той самий файл для кількох методів.
generate cert — dev HTTPS ключ + серт
hopak generate cert
# → Generating self-signed dev certificate { path: ".hopak/certs" }
# → Dev certificate ready. Re-run `hopak dev` with HTTPS enabled.
Пише .hopak/certs/dev.key + dev.crt + локальний .gitignore, щоб матеріал ніколи не потрапляв у комміт. Увімкніть HTTPS у конфігу (server.https.enabled: true) і перезапустіть hopak dev. Якщо ви вмикаєте HTTPS, не виконавши цього раніше, hopak dev відмовляється стартувати й вказує назад сюди — рантайм не фабрикує крипто сам.
Див. Recipe 18 для повного сценарію HTTPS.
Нормалізація шляху
hopak generate route прощає, як ви пишете шлях:
| Ви пишете | Створений файл |
|---|---|
search | app/routes/search.ts |
/search | app/routes/search.ts (лідируюча / видаляється) |
search.ts | app/routes/search.ts (.ts видаляється) |
posts/new.ts | app/routes/posts/new.ts |
Батьківські директорії створюються автоматично.
Кастомні шляхи проєкту
Якщо в hopak.config.ts є paths.models: 'src/domain', hopak generate model comment пише в src/domain/comment.ts — генератор поважає конфіг (див. Recipe 21).
Політика відмови — ніколи не перезаписує
Запуск будь-якого generate двічі на той самий цільовий шлях провалиться на другому:
Error: File already exists: app/models/comment.ts
Exit code 1 — безпечно запускати з npm-скриптів чи Makefile-ів. Видаліть файл (або перейменуйте), якщо справді хочете свіжий шаблон. generate cert — виняток: якщо обидва dev.key і dev.crt уже присутні, виходить 0 з Dev certificate already exists (ідемпотентно — безпечно в setup-скриптах).
23. Логувати кожен запит (з correlation-id)
Мета: один рядок на запит у логах плюс correlation-id, який можна матчити з клієнтськими тікетами. Обидва — в @hopak/core, вмикаються однією командою:
hopak use request-log
# → Patched main.ts — requestId() + requestLog() now run on every request
main.ts стає:
import { hopak, requestId, requestLog } from '@hopak/core';
await hopak().before(requestId()).after(requestLog()).listen();
На кожному запиті ви отримуєте:
GET /api/posts 200 3ms [0f4b2c…]
POST /api/auth/login 401 8ms [b1c9ae…] ! bad credentials
Id також повертається як X-Request-Id у відповіді, щоб клієнт і сервер ділили той самий тег.
Оберіть формат:
// Structured logs (one JSON object per line — great for aggregators):
.after(requestLog({ format: 'json' }))
// Extra fields per request:
.after(requestLog({ extra: (ctx) => ({ tenant: ctx.user?.tenantId }) }))
Ставте requestId() перед будь-яким middleware, що кидає — будь-який handler або middleware, що викликає ctx.log.info(...) після нього, понесе id неявно через рядок логу запиту. Використайте власний генератор, щоб замінити UUID на ULID:
.before(requestId({ generate: () => someUlid() }))
24. Додати JWT-аутентифікацію (signup, login, me, захищені routes)
Мета: повна auth на credentials з робочим signup, login та ендпоїнтом me однією командою — плюс requireAuth(), який можна накинути на будь-який маршрут.
hopak use auth
# → Created app/middleware/auth.ts
# → Created app/routes/api/auth/signup.ts
# → Created app/routes/api/auth/login.ts
# → Created app/routes/api/auth/me.ts
# → Created app/models/user.ts (only if you don't already have one)
# → Added JWT_SECRET to .env.example
# → bun add @hopak/auth jose
Скопіюйте .env.example → .env, задайте JWT_SECRET (32+ випадкових байтів — openssl rand -hex 32), потім hopak sync && hopak dev. Три ендпоїнти піднімаються без жодного додаткового коду:
curl -X POST http://localhost:3000/api/auth/signup \
-H 'content-type: application/json' \
-d '{"name":"Ada","email":"a@b.com","password":"hunter2hunter"}'
# → { "user": {...}, "token": "eyJhbGci..." }
curl -X POST http://localhost:3000/api/auth/login \
-H 'content-type: application/json' \
-d '{"email":"a@b.com","password":"hunter2hunter"}'
# → { "token": "eyJhbGci..." }
curl http://localhost:3000/api/auth/me \
-H 'authorization: Bearer eyJhbGci...'
# → { "id": 1, "role": null }
Захистіть будь-який інший маршрут через requireAuth() з згенерованого файлу middleware:
// app/routes/api/posts.ts
import { crud } from '@hopak/core';
import post from '../../models/post';
import { requireAuth } from '../../middleware/auth';
export const GET = crud.list(post);
export const POST = crud.create(post, { before: [requireAuth()] });
Доступ на основі ролей
@hopak/auth постачає requireRole(...names) — ставте його після requireAuth():
import { requireRole } from '@hopak/auth';
import { requireAuth } from '../../middleware/auth';
export const DELETE = crud.remove(post, {
before: [requireAuth(), requireRole('admin')],
});
// Non-admin → 403 Forbidden
// No token → 401 Unauthorized
// admin → handler runs
Кілька ролей — OR: requireRole('admin', 'editor'). Додайте власні claims, розширивши AuthUser:
// app/middleware/auth.ts
import 'app/types/auth';
// app/types/auth.ts
declare module '@hopak/auth' {
interface AuthUser {
tenantId: number;
}
}
Потім передайте claims: ['id', 'role', 'tenantId'] у jwtAuth({...}).
OAuth (GitHub, Google)
@hopak/auth/oauth/github та /oauth/google виставляють відповідні *Start / *Callback route-handler-и, які ділять той самий signToken, що ви вже маєте. State перевіряється stateless через HMAC — без сховища cookie:
// app/routes/api/auth/github/start.ts
import { defineRoute } from '@hopak/core';
import { githubStart } from '@hopak/auth/oauth/github';
export const GET = defineRoute({
handler: githubStart({
callbackUrl: 'http://localhost:3000/api/auth/github/callback',
stateSecret: process.env.JWT_SECRET ?? '',
}),
});
// app/routes/api/auth/github/callback.ts
import { defineRoute } from '@hopak/core';
import { githubCallback } from '@hopak/auth/oauth/github';
import user from '../../../../models/user';
import { signToken } from '../../../../middleware/auth';
export const GET = defineRoute({
handler: githubCallback({
model: user,
sign: signToken,
stateSecret: process.env.JWT_SECRET ?? '',
}),
});
Задайте GITHUB_OAUTH_ID і GITHUB_OAUTH_SECRET у .env. Нові користувачі створюються з { email, name, password: 'oauth:<uuid>' } за замовчуванням — перевизначте опцією createUser, якщо Ваша модель має інші обов’язкові поля. Google працює так само з @hopak/auth/oauth/google.
25. Еволюціонувати схему через міграції
Мета: змінити модель після першого дня без втрати даних — з оглядовим up/down, rollback-ом та audit trail.
hopak sync — для dev-bootstrap: він виконує CREATE TABLE IF NOT EXISTS при першому старті й нічого більше. Щойно вам потрібно додати колонку, за справу беруться міграції.
hopak migrate init
# → Created app/migrations/20260422T153012345_init.ts (CREATE TABLE for each model)
hopak migrate new add_role_to_user
# → Created app/migrations/20260422T160100_add_role_to_user.ts (empty up/down skeleton)
Заповніть скелет:
import type { MigrationContext } from '@hopak/core';
export const description = 'Add role column to user';
export async function up(ctx: MigrationContext): Promise<void> {
await ctx.sql`ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'`;
}
export async function down(ctx: MigrationContext): Promise<void> {
await ctx.sql`ALTER TABLE users DROP COLUMN role`;
}
Застосуйте, переглянути, відкотити:
hopak migrate up # applies pending
hopak migrate up --dry-run # preview without touching DB
hopak migrate status # applied / pending / missing
hopak migrate down # rollback last (or --steps N)
ctx.db всередині up/down — це повний клієнт Hopak — data-міграції (бекфіл нової колонки, переписування рядків) живуть у тому ж файлі, що і їхній DDL.
Транзакційний контракт:
- SQLite / Postgres: кожна міграція виконується всередині
db.transaction(). - MySQL: DDL авто-коміт, тож міграції виконуються без зовнішньої tx; ідіома — один DDL на файл, щоб падіння лишалися відновлюваними.
Щойно існує app/migrations/, hopak sync відмовляється виконуватися — еволюція схеми живе виключно в міграціях. До того моменту sync лишається найшвидшим шляхом від hopak new до робочого ендпоїнту.
Якщо ви змінюєте колонку моделі, поки ще на sync, наступний hopak dev друкує drift-попередження, що вказує на hopak migrate init — природний момент перейти до міграцій.