Документація / Рецепти

Рецепти

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.

Зміст

  1. Створити REST-ресурс
  2. Валідація вхідних даних
  3. Приховати чутливі поля
  4. Додати власний endpoint
  5. Кастомізувати один CRUD-endpoint
  6. Кинути типізовану помилку
  7. Оголосити власну помилку
  8. Запит до БД усередині handler-а
  9. Зв’язки між моделями
  10. Фільтри з операторами — gte, like, in, between, OR, NOT
  11. Завантажити зв’язані записи через include — без N+1
  12. Upsert та масові записи
  13. Агрегати — sum, avg, count (опціонально з groupBy)
  14. Cursor-пагінація (keyset)
  15. Транзакції та row-локи
  16. Вибрати конкретні колонки — select, distinct
  17. Вибрати або змінити базу даних
  18. Увімкнути HTTPS у локальному dev
  19. Дозволити CORS з Вашого фронтенду
  20. Обслуговувати статичні файли
  21. Перемістити вихідний код в іншу директорію
  22. Генерація файлів через CLI
  23. Логувати кожен запит (з correlation-id)
  24. Додати JWT-аутентифікацію (signup, login, me, захищені routes)
  25. Еволюціонувати схему через міграції

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.tsGET /health (та будь-який експортований метод)
app/routes/index.tsGET /
app/routes/api/posts.ts/api/posts
app/routes/posts/[id].ts/posts/:idctx.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
ValidationError400VALIDATION_ERROR
Unauthorized401UNAUTHORIZED
Forbidden403FORBIDDEN
NotFound404NOT_FOUND
Conflict409CONFLICT
RateLimited429RATE_LIMITED
InternalError500INTERNAL_ERROR
ConfigError500CONFIG_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, їх не підхоплює сканер. Типові шаблони:

Просто 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.dbundefined, коли немає моделей

Якщо в проєкті нуль моделей, 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, notInIN (...), NOT IN (...)Масив значень
betweenBETWEEN x AND yВключний діапазон — [min, max]
containsLIKE '%x%'Пошук підрядка, wildcards автоекрануються
startsWithLIKE 'x%'Префіксний пошук
endsWithLIKE '%x'Суфіксний пошук
likeLIKE 'x'Сирий патерн — % і _ контролюєте самі
ilikeILIKE 'x' (PG) / LIKE (SQLite+MySQL, case-insensitive за замовчуванням)Case-insensitive підрядок/патерн
isNull, isNotNullIS 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' },
});

Під капотом: 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,
    };
  },
});

Нюанси

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, блокує писачів. Використовується для “я читаю цей рядок і не хочу, щоб він змінювався, поки я вирішую, що робити”.

Застереження

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'>[]. Корисно, коли:

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

Що відбувається:

  1. hopak.config.ts пишеться з database: { dialect: 'postgres', url: process.env.DATABASE_URL }.
  2. package.json містить postgres (або mysql2 для MySQL) як залежність — bun install підбирає її в тому ж проході hopak new.
  3. .env.example містить плейсхолдер: DATABASE_URL=postgres://user:pass@localhost:5432/myapp.
  4. 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

Що це робить:

  1. Ставить драйверbun add postgres (або bun add mysql2). SQLite поставляється з Bun — ставити нічого.
  2. Переписує блок database: в hopak.config.ts. Патчер впізнає голий дефолт з hopak new і заміняє його чисто; налаштований блок (власний шлях до sqlite-файлу, додаткові параметри URL, конфіг ssl тощо) лишається недоторканим, а команда друкує сніпет, який треба вставити вручну, щоб Ваше налаштування не було мовчки викинуто.
  3. Додає 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(...) — все працює ідентично на кожному діалекті.

Відмінності діалектів (короткий огляд)

РічSQLitePostgresMySQL
Встановленняпостачається з Bunhopak use postgreshopak use mysql
Пакет драйвераbun:sqlitepostgres (postgres.js)mysql2
ilikeLIKE (case-insensitive ASCII)нативний ILIKELIKE (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. Дві типові схеми:

Права на файли мають значення — файл ключа має бути chmod 600 і належати користувачу застосунку, ніколи не доступний всьому світу.

HTTP і HTTPS одночасно

Сьогодні не підтримується. https.enabled: true заміщає HTTP-лісенер; https.port — єдиний порт. Якщо треба обидва, запускайте за reverse proxy, що обробляє редирект 80 → 443 (Caddy робить це за замовчуванням).

Порти — що де використовується

КонфігDev
server.portHTTP-лісенер
server.https.enabled: true + https.portHTTPS-лісенер; 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' не те саме, що:

Браузери надсилають хедер Origin точно як origin сторінки. Якщо сумніваєтеся — скопіюйте з вкладки Network у devtools.

Чекліст відлагодження

Коли браузер блокує виклик, пройдіться так:

  1. Відкрийте devtools → Network → запит, який падає → Headers. Чи присутній request-хедер Origin?
  2. Перевірте відповідь. Чи є в ній Access-Control-Allow-Origin? Якщо немає → сервер не розпізнав origin (друкарська помилка в origins). Якщо є, але не той → проблема точного збігу (коса в кінці, розбіжність схеми).
  3. Це preflight? Запити з content-type: application/json + credentials тригерять preflight OPTIONS спочатку. Перевірте, що OPTIONS повертає 204 з правильними хедерами. Якщо 404 — маршруту для OPTIONS немає — Hopak обробляє preflight, лише коли cors сконфігуровано, тож переконайтеся, що hopak.config.ts дійсно завантажено (hopak check його друкує).
  4. Credentialed-запит? Клієнт має надсилати fetch(url, { credentials: 'include' }) І сервер має мати credentials: true, І origin-и мають бути явними (не *). Усі три, інакше кукі не підуть.
  5. Перезапустіть сервер. 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 перевіряє в такому порядку:

  1. Файлові маршрути в app/routes/ (будь-який HTTP-метод)
  2. CRUD-маршрути, згенеровані model(...)
  3. Статичний файл у public/ (лише GET і HEAD)
  4. 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-Controlpublic, max-age=300
ETagweak ETag з size + mtime
Last-Modifiedmtime файлу

Якщо потрібне довгострокове кешування для 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
      },
    });
  },
});

Безпека

Коли взагалі пропустити статику

У продакшені статичні ассети зазвичай живуть на 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',
  },
});

Після цього:

Усі конфігуровані шляхи

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' }

Міграція наявного проєкту

  1. Перенесіть файли: mv app/models src/domain, mv app/routes src/api.
  2. Додайте paths у hopak.config.ts.
  3. Запустіть hopak check — він друкує, що просканував, і підтверджує збіг кількості моделей/маршрутів. Якщо щось не так, check виходить зі статусом 1 (відмінно для CI).
  4. Перезапустіть 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 прощає, як ви пишете шлях:

Ви пишетеСтворений файл
searchapp/routes/search.ts
/searchapp/routes/search.ts (лідируюча / видаляється)
search.tsapp/routes/search.ts (.ts видаляється)
posts/new.tsapp/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.

Транзакційний контракт:

Щойно існує app/migrations/, hopak sync відмовляється виконуватися — еволюція схеми живе виключно в міграціях. До того моменту sync лишається найшвидшим шляхом від hopak new до робочого ендпоїнту.

Якщо ви змінюєте колонку моделі, поки ще на sync, наступний hopak dev друкує drift-попередження, що вказує на hopak migrate init — природний момент перейти до міграцій.