Recipes
25 step-by-step recipes — REST, validation, relations, auth, migrations.
Common backend tasks, step by step. Every recipe shows where the file goes, the code, how to run it, and what you should see. Start from a freshly scaffolded project:
hopak new my-app # SQLite by default (zero-install, works offline)
cd my-app
hopak new runs bun install for you — no separate step. Want a different dialect from the start? Pass --db:
hopak new my-app --db postgres # or --db mysql / --db sqlite
Picking the dialect up front writes the right database: block into hopak.config.ts, adds the driver to package.json, and seeds .env.example with a DATABASE_URL placeholder. See Recipe 17 for the full flow of both hopak new --db and hopak use.
Every recipe below assumes the default SQLite unless explicitly noted — the runtime behavior is identical on every dialect, so code examples are copy-paste portable.
Contents
- Create a REST resource
- Validate input
- Hide sensitive fields
- Add a custom endpoint
- Customize one CRUD endpoint
- Throw a typed error
- Define a custom error
- Query the database inside a handler
- Relations between models
- Filter with operators —
gte,like,in,between,OR,NOT - Load related rows with
include— N+1-free - Upsert and bulk writes
- Aggregate — sum, avg, count (with optional
groupBy) - Cursor pagination (keyset)
- Transactions and row locks
- Project specific columns —
select,distinct - Pick or switch the database
- Enable HTTPS for local dev
- Allow CORS from your frontend
- Serve static files
- Move your source somewhere else
- Scaffold files from the CLI
- Log every request (with a correlation id)
- Add JWT auth (signup, login,
me, gated routes) - Evolve the schema with migrations
1. Create a REST resource
Goal: expose GET/POST /api/posts and GET/PUT/PATCH/DELETE /api/posts/:id.
1. Generate the model + CRUD route files:
hopak generate model post
hopak generate crud post
The first command writes app/models/post.ts. The second writes two route files — app/routes/api/posts.ts (list + create) and app/routes/api/posts/[id].ts (read + replace + patch + delete). Open either file; the entire REST surface is there as plain code you can read and edit — nothing is synthesized at runtime.
2. Add your fields to the model:
// 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. Start the server:
hopak dev
On first boot Hopak creates the SQLite file at .hopak/data.db and runs CREATE TABLE IF NOT EXISTS for every model. Safe to repeat — hopak sync does the same thing explicitly if you prefer to separate schema sync from server start (handy for CI or a fresh Postgres / MySQL database).
4. Try it from another terminal:
curl -X POST http://localhost:3000/api/posts \
-H 'content-type: application/json' \
-d '{"title":"Hello","content":"World"}'
Expected response (201 Created):
{ "id": 1, "title": "Hello", "content": "World", "published": false,
"createdAt": "...", "updatedAt": "..." }
5. List them:
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. Verify what’s actually registered:
hopak check
# ✓ Models 1 loaded (post)
# ✓ Routes 6 file route(s)
Six endpoints from two generated files: list, read, create, replace, patch, delete — all paginated and validated. The plural segment (/api/posts) comes from pluralize('post') — irregular plurals are handled (story → stories, box → boxes). Don’t want endpoints for a given model? Just don’t run hopak generate crud for it — the model still becomes a table, you just don’t expose HTTP routes.
2. Validate input
Goal: reject malformed requests with clear, per-field error messages.
Validation is generated from the model — you don’t write a separate schema. POST and PUT validate the full object; PATCH validates a partial one automatically.
1. Add constraints:
// 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. Send a bad request:
curl -X POST http://localhost:3000/api/users \
-H 'content-type: application/json' \
-d '{"name":"X","email":"not-an-email","age":5,"role":"superman"}'
Response (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\""]
}
}
Every failing field has an array of human-readable messages under details.
.unique() — where the check happens
.unique() is a database-level constraint — it’s enforced by SQLite when the row is inserted. Hopak catches the UNIQUE constraint failed and surfaces it as 409 Conflict, not 400:
{ "error": "CONFLICT", "message": "Unique constraint violated" }
So there are two response shapes to expect from a create call:
| Bad input | Response |
|---|---|
| Wrong shape / type / range | 400 VALIDATION_ERROR with details |
| Shape valid, already exists | 409 CONFLICT |
Validate in a custom route
When writing your own handler, validate with the same schema the CRUD helpers use:
// 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 }) gives the PATCH-flavoured schema. result.errors is Record<field, string[]> — the same shape the CRUD helpers send back.
Sensitive fields in custom routes:
password(),secret(), andtoken()are stripped by the serializer, andcrud.list/crud.create/ etc. pass every row through it. A custom handler that returns a model row directly —return ctx.db!.model('user') .findOne(id)— skips that step, and the DB column (argon2 hash, API key, etc.) lands in the response. Always wrap the row inserializeForResponse(row, model)before returning.
Throw your own field errors
If a rule is domain-specific (not a field constraint), throw ValidationError with a details map — it renders identically:
if (body.password === body.email) {
throw new ValidationError('Invalid body', {
password: ['Password must differ from email'],
});
}
3. Hide sensitive fields
Goal: store passwords and API tokens in the database, but never return them in responses.
Three field types are marked sensitive by Hopak and stripped from every JSON response:
| Field | Use for |
|---|---|
password() | Login passwords (still stored as plain string — hash them yourself before insert) |
secret() | Signing keys, OAuth client secrets, internal tokens |
token() | API keys, bearer tokens, refresh tokens |
Exclusion happens in the serializer for every CRUD endpoint: list, single, create reply, update reply. It also applies to any value you return from a custom route that includes one of these columns (for example 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(),
});
Verify:
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"}'
Response — notice password and apiKey are missing, even though they were stored:
{ "id": 1, "name": "Alice", "email": "a@b.com",
"createdAt": "...", "updatedAt": "..." }
Same when you GET /api/users/1.
Reading the value on the server
The field is not removed from the database — only from JSON output. Server-side code still sees it:
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);
Just don’t return row directly after touching row.password — the serializer will drop the field anyway, but the habit to build is never include it in an API surface.
Writing the value
POST / PATCH bodies accept the field normally — validation still runs (.min(8) etc.). Hash before insert with Bun.password.hash(plain) (argon2id by default) in a custom route or a pre-insert hook.
Hopak does not auto-hash.
password()means don’t leak on read, not encrypt on write.
4. Add a custom endpoint
Goal: create POST /posts/:id/publish that flips the published flag.
The URL is derived from the file path under app/routes/. Square brackets mark dynamic segments.
// 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 };
},
});
Test:
curl -X POST http://localhost:3000/posts/1/publish
# → { "previous": false, "updated": { "id": 1, "published": true, ... } }
File path → URL
| File | URL |
|---|---|
app/routes/health.ts | GET /health (and any method exported) |
app/routes/index.ts | GET / |
app/routes/api/posts.ts | /api/posts |
app/routes/posts/[id].ts | /posts/:id — ctx.params.id is a string |
app/routes/posts/[id]/publish.ts | /posts/:id/publish |
app/routes/files/[...rest].ts | /files/* catch-all — ctx.params.rest is the remaining path |
All path params arrive as strings. Convert yourself (Number(ctx.params.id)), or validate with the model schema.
Multiple methods in one file
Export one function per HTTP method:
// 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 }) });
An un-exported method returns 405 Method Not Allowed automatically. A default export is treated as GET.
Reading the request
Inside the handler, everything you need is on 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
Return anything — plain object, string, Response, null — the framework serializes it. See Request context for the full surface.
5. Customize one CRUD endpoint
Goal: replace just the POST /api/posts handler with custom logic, keep the other five endpoints as they are.
The CRUD files are plain source. Open app/routes/api/posts.ts and replace the POST export with your own 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 still comes from crud.list; the item-level file (posts/[id].ts) is untouched.
Disable a single verb
Delete that verb’s export. The router will answer 405 Method Not Allowed with an Allow: header listing the verbs that remain.
// 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
Skip CRUD entirely for this model
Don’t run hopak generate crud for it, or delete the two generated files. The model still becomes a table and can be queried via ctx.db!.model('post') from any custom route you write.
6. Throw a typed error
Goal: stop processing and return a proper HTTP status with a JSON body.
Import a subclass of HopakError and throw it anywhere — the framework serialises it for you.
// 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 produces:
HTTP/1.1 404 Not Found
Content-Type: application/json
{ "error": "NOT_FOUND", "message": "Post 42 not found" }
Built-in subclasses
| Class | Status | error code |
|---|---|---|
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 |
Every subclass accepts an optional second details argument that is rendered under "details":
throw new Unauthorized('Invalid token', { reason: 'expired' });
// 401 { "error": "UNAUTHORIZED", "message": "Invalid token",
// "details": { "reason": "expired" } }
Unknown errors
Anything that is not a HopakError (a raw Error, a rejected promise, a thrown string) becomes:
HTTP/1.1 500 Internal Server Error
{ "error": "INTERNAL_ERROR", "message": "Internal server error" }
The original error is logged with ctx.log.error(...) — nothing about the cause leaks to the client. Set server.exposeStack: true in dev to include the stack trace in the response body (handy when debugging; never enable in production).
7. Define a custom error
Goal: introduce a domain-specific error like PaymentFailed (402).
Subclass HopakError and override status and code. Both fields are readonly, so declare them with 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';
}
Use it from any handler:
import { PaymentFailed } from '../lib/errors';
throw new PaymentFailed('Insufficient funds', { available: 5, required: 20 });
Response:
HTTP/1.1 402 Payment Required
Content-Type: application/json
{ "error": "PAYMENT_FAILED", "message": "Insufficient funds",
"details": { "available": 5, "required": 20 } }
Where to put them
Any path under the project works — the error classes are plain TypeScript, not picked up by a scanner. Common patterns:
app/lib/errors.ts— one shared fileapp/models/<domain>/errors.ts— co-located with the feature that raises them
Just import and throw. There’s no registration step.
details is free-form
The constructor accepts anything serialisable as the second argument. It’s rendered verbatim under "details", so choose a shape that’s useful for the client:
throw new QuotaExceeded('Monthly quota exceeded', {
limit: 1000,
used: 1000,
resetsAt: '2026-05-01T00:00:00Z',
});
8. Query the database inside a handler
Goal: read/write rows from a custom route using the same typed client CRUD uses.
ctx.db.model('<name>') returns a client with full CRUD, filters, ordering, and pagination.
// 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 };
},
});
Full client surface
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
All methods are fully typed from the model — data has to match the field shape, row.title is string, etc.
Filters — what where supports
Plain values mean equality (where: { published: true }). For comparisons, substring matches, IN, BETWEEN, OR, NOT — see Recipe 10. A full reference of every operator lives there.
Pagination defaults
client.findMany({}) // no limit, no offset
client.findMany({ limit: 20 }) // LIMIT 20
client.findMany({ limit: 20, offset: 40 })
The typed client passes limit through as-is. CRUD endpoints (reached via HTTP) enforce a cap of 100 on the ?limit= query param so public traffic can’t ask for millions of rows; direct client calls inside your handlers have no such cap.
Raw SQL via db.sql
Anything the typed client can’t do, reach for db.sql — a tagged template that parameterises interpolations automatically and returns typed rows:
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} interpolations become driver-native placeholders (? on SQLite / MySQL, $N on Postgres). Values are never inlined into the SQL text, so it’s safe with user input.
For the <1% of cases where db.sql isn’t enough (query-builder composition, Drizzle plugins) drop further: ctx.db!.builder() returns the dialect’s native Drizzle instance. See database docs for the full escape hierarchy.
ctx.db is undefined when there are no models
If the project has zero models, Hopak doesn’t open a database — ctx.db stays undefined. Handlers that require it check explicitly:
if (!ctx.db) throw new InternalError('Database not configured');
const posts = await ctx.db.model('post').findMany();
A normal app with at least one model in app/models/ always has ctx.db set.
9. Relations between models
Goal: one author has many posts; each post belongs to one author.
Hopak has two kinds of relation fields:
| Field | Creates a column? | Meaning |
|---|---|---|
belongsTo('user') | yes — user_id (integer FK) | this row points to a parent |
hasOne('profile') / hasMany('post') | no — virtual | hint for tooling, no schema impact |
// 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
});
Create rows:
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, ... }
The FK field in the API
The API field is what you named in the model (author). The column name is <field>_id under the hood, but you don’t interact with it — Hopak maps both directions. Send { "author": 1 } in JSON; filter with { where: { author: 1 } } in the client; receive "author": 1 in responses.
Foreign-key integrity is enforced by SQLite — inserting a post with author: 999 where user 999 doesn’t exist returns 409 CONFLICT.
Eager-load relations
Hopak batches relation fetches into a single WHERE id IN (...) query — no N+1 problem. See Recipe 11 for the full API:
await ctx.db.model('post').findMany({ include: { author: true } });
await ctx.db.model('user').findMany({ include: { posts: true, profile: true } });
Migrations
hopak sync (and hopak dev’s first boot) creates the column and the FK constraint via CREATE TABLE IF NOT EXISTS — idempotent replay, no ALTER TABLE. Changing a belongsTo target on an existing table doesn’t alter data; for prototyping, delete .hopak/data.db (or drop the table on Postgres / MySQL) and sync again.
10. Filter with operators — gte, like, in, between, OR, NOT
Goal: build real-world findMany queries — ranges, substring matches, OR branches, nullability checks — without writing SQL.
Every filter lives under where. A literal value means equality. An object with one of the operator keys switches to the corresponding comparison:
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,
});
Operator reference
| Operator | SQL | Notes |
|---|---|---|
eq, neq | =, != | Equality (explicit); eq is the default for literal values |
gt, gte, lt, lte | >, >=, <, <= | Numeric / date comparisons |
in, notIn | IN (...), NOT IN (...) | Array of values |
between | BETWEEN x AND y | Inclusive range — [min, max] |
contains | LIKE '%x%' | Substring match, wildcards auto-escaped |
startsWith | LIKE 'x%' | Prefix match |
endsWith | LIKE '%x' | Suffix match |
like | LIKE 'x' | Raw pattern — you control % and _ yourself |
ilike | ILIKE 'x' (PG) / LIKE (SQLite+MySQL, case-insensitive by default) | Case-insensitive substring/pattern |
isNull, isNotNull | IS NULL, IS NOT NULL | Pass true as the value |
Combining clauses
| Key | Behavior |
|---|---|
| Top-level fields | Implicit AND across all keys |
AND: [...] | Explicit AND — useful for combining pre-built clauses |
OR: [...] | Any of the branches matches |
NOT: {...} | Negate a sub-clause |
// Posts that are published AND (views >= 100 OR featured)
await posts.findMany({
where: {
published: true,
OR: [{ views: { gte: 100 } }, { featured: true }],
},
});
Gotcha: LIKE wildcards are escaped
When you pass a literal % or _ into contains / startsWith / endsWith, Hopak escapes them for you. contains: '100%' matches a literal “100%” in the data, not “anything ending in 100 followed by anything”. For raw LIKE patterns where you control the wildcards yourself, use like: '...'.
11. Load related rows with include — N+1-free
Goal: fetch posts with their authors, or users with their posts — in a single batched query per relation (not one query per primary row).
// 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 — fetch the parent
const posts = await ctx.db.model('post').findMany({
include: { author: true },
});
// [{ id: 1, title: 'Hello', author: { id: 7, name: 'Alice', ... } }, ...]
Under the hood: one SELECT * FROM posts + one SELECT * FROM users WHERE id IN (<unique author ids>). Hopak indexes the result and stitches it onto each post. Two queries total, regardless of how many posts you fetched.
hasMany — fetch the children, filtered and ordered
const users = await ctx.db.model('user').findMany({
include: {
posts: {
where: { published: true },
orderBy: [{ field: 'createdAt', direction: 'desc' }],
},
},
});
// [{ id: 1, name: 'Alice', posts: [{...}, {...}] }, ...]
One query for users, one query for SELECT * FROM posts WHERE author IN (<user ids>) AND published = true ORDER BY created_at DESC. Then grouped by FK and attached. Parents with no matching children get posts: [].
hasOne — fetch single child (or null)
const users = await ctx.db.model('user').findMany({
include: { profile: true },
});
// [{ id: 1, ..., profile: { bio: 'Hi' } }, { id: 2, ..., profile: null }]
Multiple includes in one call
await ctx.db.model('user').findMany({
include: {
posts: true,
profile: true,
comments: { orderBy: [{ field: 'createdAt', direction: 'desc' }], limit: 5 },
},
});
Still N+1-free: one query for users, one per relation. Three queries total for this example, no matter how many users came back.
12. Upsert and bulk writes
Goal: “insert-or-update in one call”, plus createMany / updateMany / deleteMany for bulk operations.
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' },
});
- If no row matches
where→ inserts{ ...where, ...create } - If a row matches → updates it with
update, returns the fresh row
Under the hood: ON CONFLICT (email) DO UPDATE on SQLite + Postgres, ON DUPLICATE KEY UPDATE on MySQL. The where keys must correspond to a UNIQUE constraint or primary key, otherwise the conflict never triggers.
Batch operations
All three return { 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 } },
});
Gotcha: deleteMany({}) deletes everything
An empty where object matches all rows — deliberately, so deleteMany({}) is the explicit “truncate via the ORM” escape hatch. If you want to be sure a filter is present at runtime, assert it yourself before calling.
13. Aggregate — sum, avg, count (with optional groupBy)
Goal: run statistics over the rows without writing SQL.
Single-row aggregate (across all matching rows)
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'] counts non-null values of that column (useful on nullable fields). count: '_all' is COUNT(*) — every row.
Grouped aggregate — one result row per distinct group
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 flips the return type to an array of result rows. Each row contains the group-by column values plus the aggregates. To sort or paginate, pull the result down and do it in JS — or use db.sql for server-side ORDER BY sum(views) DESC LIMIT 10.
14. Cursor pagination (keyset)
Goal: paginate large tables efficiently — LIMIT/OFFSET scans skipped rows, cursor pagination jumps straight to the next page in 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,
});
The cursor column must be in orderBy — the direction there decides whether the cursor means “strictly after” (asc) or “strictly before” (desc).
Typical API shape for infinite scroll
// 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,
};
},
});
Gotchas
- Single-column cursors only. Multi-column keyset (e.g.
(createdAt, id)for stable ordering whencreatedAtties) needs tuple-comparison syntax that differs across dialects. For that case, sort by a stably-unique column (likeid) or drop todb.sqlwith a composite WHERE. - Cursor values must be non-null. Passing
cursor: { id: null }throws. - The cursor key must appear in
orderBy; otherwise the direction is ambiguous and Hopak throws with a pointer.
15. Transactions and row locks
Goal: atomic multi-write operations, plus safe concurrent updates via SELECT ... FOR UPDATE.
Basic transaction — commit on resolve, rollback on 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.
The tx argument is a scoped Database — same API as ctx.db, but every tx.model(...) call participates in the transaction. Outside queries on ctx.db are not in the transaction.
Rollback propagates from any 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.
Typed HopakError subclasses (NotFound, Forbidden, etc.) work the same way — the framework error handler formats the response, the transaction rolls back cleanly.
Pessimistic row locking — SELECT ... FOR UPDATE
For “read-then-modify” patterns under concurrency (counters, balances, queues), reading with lock: 'forUpdate' takes an exclusive lock on the row until the transaction commits or rolls back. A second concurrent transaction doing the same read waits for the first.
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 });
});
Without the lock, two concurrent increments race and one is lost. With it, they serialize: second waits, reads the committed new value, adds on top.
| Dialect | Behavior |
|---|---|
| Postgres | Native SELECT ... FOR UPDATE |
| MySQL | Native SELECT ... FOR UPDATE |
| SQLite | Silent no-op — SQLite transactions are already single-writer (exclusive) |
lock: 'forShare' is the weaker variant — shared lock, multiple readers OK, blocks writers. Used for “I’m reading this row and don’t want it to change while I decide what to do.”
Caveats
- No nested transactions. Calling
tx.transaction(...)inside a transaction throws. For partial rollback within a transaction, use SAVEPOINTs viatx.builder(). lockis supported onfindOne/findOrFail/findMany. The lock applies only to the primary rows — anincludeissues a separate, unlocked query for relations.
16. Project specific columns — select, distinct
Goal: return only the columns the client needs (select) and deduplicate rows (distinct).
select — column projection
const rows = await ctx.db.model('post').findMany({
select: ['id', 'title'],
where: { published: true },
});
// [{ id: 1, title: 'Hello' }, ...] — no `content`, no `views`, nothing else
Typed as Pick<TRow, 'id' | 'title'>[]. Useful when:
- Rows carry heavy fields (
content: text(),json<T>(), blob-like data) and you only need a list - You want the wire format to match a specific client contract
- You’re joining via
includeand don’t want the base table’s bulky columns
select + include plays nicely
const articles = await ctx.db.model('article').findMany({
select: ['id', 'title'],
include: { author: true },
});
// [{ id: 1, title: '...', author: { id: 7, name: 'Alice', email: '...' } }]
Even though you asked for only id and title, Hopak transparently pulls the FK column it needs for the include, then replaces it with the nested author object before returning.
distinct: true — deduplicate across all dialects
const titles = await posts.findMany({
select: ['title'],
distinct: true,
orderBy: [{ field: 'title', direction: 'asc' }],
});
distinct: ['col'] — Postgres DISTINCT ON
Postgres has SELECT DISTINCT ON (col) — “one row per distinct value of col, with ORDER BY deciding which row wins.” Useful for “each author’s most recent post” queries:
// Postgres only
await posts.findMany({
distinct: ['author'],
orderBy: [
{ field: 'author', direction: 'asc' }, // must come first
{ field: 'createdAt', direction: 'desc' },
],
});
On SQLite / MySQL this throws with a pointer — that SQL extension isn’t standard, and there’s no clean portable rewrite. Use db.sql or a subquery if you need the same semantics cross-dialect.
17. Pick or switch the database
Goal: use Postgres or MySQL instead of the default SQLite.
There are two entry points depending on where you are.
17a. At project creation — hopak new --db postgres
Pick the dialect up front; hopak new wires everything in one pass.
hopak new my-app --db postgres
cd my-app
What happens:
hopak.config.tsis written withdatabase: { dialect: 'postgres', url: process.env.DATABASE_URL }.package.jsonlistspostgres(ormysql2for MySQL) as a dependency —bun installpicks it up during the samehopak newrun..env.examplecontains a placeholder:DATABASE_URL=postgres://user:pass@localhost:5432/myapp.README.mdin the project tells you the extra setup step (“copy.env.example→.env, runhopak sync”).
Next:
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. In an existing project — hopak use postgres
Switch an already-scaffolded project from one dialect to another.
hopak use postgres
What this does:
- Installs the driver —
bun add postgres(orbun add mysql2). SQLite ships with Bun — nothing to install there. - Rewrites the
database:block inhopak.config.ts. The patcher recognizes the bare default fromhopak newand replaces it cleanly; a tuned block (custom sqlite file path, extra URL params,sslconfig, etc.) is left alone and the command prints a snippet for you to paste manually so it never silently discards your tuning. - Adds
DATABASE_URLto.env.exampleif not already present.
Next:
# 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
The rest of the project code is unchanged — models, CRUD routes, ctx.db.model(...) — all work identically on every dialect.
Dialect differences (summary)
| Thing | SQLite | Postgres | MySQL |
|---|---|---|---|
| Install | bundled with Bun | hopak use postgres | hopak use mysql |
| Driver package | bun:sqlite | postgres (postgres.js) | mysql2 |
ilike | LIKE (case-insensitive ASCII) | native ILIKE | LIKE (case-insensitive collation) |
distinct: ['col'] | throws | DISTINCT ON | throws |
lock: 'forUpdate' | silent no-op (serial writes already) | native FOR UPDATE | native FOR UPDATE |
| Unique on TEXT | inline UNIQUE | inline UNIQUE | separate UNIQUE KEY (col(191)) — handled internally |
| FK constraints emitted | skipped | yes | yes |
Everything listed as “throws” or “handled internally” is about how the feature is emitted, not whether your code has to change — you still write .unique() or lock: 'forUpdate' in the same place.
18. Enable HTTPS for local dev
Goal: test your frontend against https://localhost:3443 using a self-signed cert.
1. Generate the dev cert:
hopak generate cert
# → Generating self-signed dev certificate { path: ".hopak/certs" }
# → Dev certificate ready. Re-run `hopak dev` with HTTPS enabled.
This runs openssl req -x509 once and writes two files plus a .gitignore that keeps them out of version control:
.hopak/certs/
├── dev.key # private key (gitignored)
├── dev.crt # self-signed cert (gitignored)
└── .gitignore # `*` — ignores everything except itself
Requires openssl on the machine. macOS ships it. On Ubuntu/Debian: apt install openssl. On Alpine: apk add openssl.
2. Turn HTTPS on in the config:
// hopak.config.ts
import { defineConfig } from '@hopak/core';
export default defineConfig({
server: { https: { enabled: true, port: 3443 } },
});
3. Restart the dev server:
hopak dev
Hopak reads the cert pair from .hopak/certs/dev.{key,crt} and serves both HTTP (port 3000) and HTTPS (port 3443). If the files aren’t there it fails fast with a pointer to hopak generate cert — nothing is synthesized behind your back at boot.
4. Verify:
curl -k https://localhost:3443/ # -k accepts the self-signed cert
Browser will show a warning the first time — that’s expected for a self-signed cert. Delete .hopak/certs/ and re-run hopak generate cert to re-issue.
Trust the cert (remove the browser warning)
If the warning is blocking your frontend (e.g. a cookie with SameSite=None; Secure won’t set), trust the cert once:
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
Restart the browser to pick up the new trust store.
Production certificates
Supply real cert and key paths. Both files need to be readable by the user running Hopak:
// hopak.config.ts
server: {
https: {
enabled: true,
port: 443,
cert: '/etc/ssl/myapp.crt',
key: '/etc/ssl/myapp.key',
},
}
Port 443 requires root on Linux/macOS. Two common patterns:
- Reverse proxy (recommended): let Nginx/Caddy/Cloudflare terminate TLS and proxy plain HTTP to Hopak on
:3000. Keephttps.enabled: falseon the app side. - Capabilities / setcap:
sudo setcap cap_net_bind_service=+ep $(which bun)lets the non-root user bind to 443 directly.
File permissions matter — the key file should be chmod 600 and owned by the app user, never world-readable.
HTTP and HTTPS at the same time
Not supported today. https.enabled: true replaces the HTTP listener; https.port is the only port. If you need both, run behind a reverse proxy that handles the 80 → 443 redirect (Caddy does this by default).
Ports — what gets used where
| Config | Dev |
|---|---|
server.port | HTTP listener |
server.https.enabled: true + https.port | HTTPS listener; HTTP listener is not started |
No https.port set | Falls back to server.port; if that’s 3000, HTTPS binds to 3000 |
Set https.port: 3443 explicitly during dev so your frontend can keep using :3000 for plain HTTP while you test TLS.
19. Allow CORS from your frontend
Goal: let a Vite/Next frontend at http://localhost:5173 call your API with cookies.
CORS is off by default — cross-origin browser requests get no CORS headers and fail. Enable per-origin:
// hopak.config.ts
import { defineConfig } from '@hopak/core';
export default defineConfig({
cors: {
origins: ['http://localhost:5173', 'https://myapp.com'],
credentials: true,
},
});
Preflight (OPTIONS) is handled automatically by the CORS layer — your handlers never see it.
Public APIs
Wildcard — no cookies:
cors: { origins: '*' }
Gotcha: * + credentials: true is a browser rejection
The CORS spec forbids Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true. Browsers will refuse the response even if Hopak sends both — the fetch rejects with a generic “CORS error” in devtools.
If you need cookies, list the exact origins:
cors: {
origins: ['http://localhost:5173', 'https://app.myapp.com'],
credentials: true,
}
Origin string must match exactly
'http://localhost:5173' is not the same as:
'localhost:5173'(missing scheme)'http://localhost:5173/'(trailing slash)'http://127.0.0.1:5173'(different host)
Browsers send the Origin header exactly as the page’s origin. Copy-paste it from devtools’ Network tab when in doubt.
Debugging checklist
When the browser is blocking a call, work through this:
- Open devtools → Network → the failing request → Headers. Is the
Originrequest header present? - Check the response. Does it have
Access-Control-Allow-Origin? If missing → server didn’t recognise the origin (typo inorigins). If present but wrong → exact-match issue (trailing slash, scheme mismatch). - Is it a preflight? Requests with
content-type: application/json+ credentials trigger a preflightOPTIONSfirst. Check that theOPTIONSreturns204with the right headers. If it returns404, the route doesn’t exist forOPTIONS— Hopak handles preflight only whencorsis configured, so verifyhopak.config.tsis actually loaded (hopak checkprints it). - Credentialed request? The client must send
fetch(url, { credentials: 'include' })AND the server must havecredentials: trueAND origins must be explicit (not*). All three, or cookies won’t flow. - Restart the server.
hopak devpicks up config changes on file save, but if you’re not sure, Ctrl-C and rerun — config-load errors print to stdout.
Same-origin? No CORS needed
If your frontend and backend are served from the same origin (e.g. https://myapp.com for both), the browser doesn’t send Origin and CORS doesn’t apply. Leave the cors block out of the config.
20. Serve static files
Goal: serve favicon.ico, images, a built SPA, or any other file straight from disk.
Basic — drop files in public/
By default Hopak serves anything inside public/ at the URL root. No code, no config.
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
The Content-Type is detected automatically from the file extension.
Use a different directory
Serve from static/ or web/dist/ (e.g. a Vite build output) — set paths.public:
// hopak.config.ts
import { defineConfig } from '@hopak/core';
export default defineConfig({
paths: { public: 'web/dist' }, // relative to project root
});
Absolute paths work too:
paths: { public: '/var/www/myapp' }
Restart the server — the directory is resolved once on boot.
Disable static files entirely
If your API should return nothing for unknown URLs (no public/ lookup), point paths.public at a directory that doesn’t exist. A missing directory is a no-op — requests fall straight through to 404:
paths: { public: '.hopak/nothing' }
No error at boot — Hopak only hits the filesystem on actual GET requests.
Route precedence
For every incoming request, Hopak checks in this order:
- File-based routes in
app/routes/(any HTTP method) - CRUD routes generated by
model(...) - Static file in
public/(onlyGETandHEAD) - 404 Not Found with a JSON body
So if you have app/routes/index.ts and public/index.html, visiting / runs the route handler — the HTML is ignored. This is useful: keep your API JSON at /, keep a marketing page at /landing.html by naming the static file differently.
SPA fallback (client-side routing)
A Vue/React/Svelte SPA needs /any/unknown/path to serve index.html so the client router can handle it. Add a catch-all route that reads and returns the SPA entry point:
// 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' } });
},
});
With this, /assets/app.js still comes from public/ (file route has lower specificity than static for existing files? — actually no: this catch-all wins over static, so the route runs for every URL). The startsWith('/api/') guard is the usual pattern to let API 404s pass through.
For a stricter split, serve the SPA from a different mount by putting the build into public/app/ and using app/routes/app/[...rest].ts — only /app/* hits the fallback.
Cache headers
Defaults are conservative and currently not configurable:
| Header | Value |
|---|---|
Cache-Control | public, max-age=300 |
ETag | weak ETag derived from size + mtime |
Last-Modified | file mtime |
If you need long-lived caching for fingerprinted assets (e.g. app.abc123.js), serve them from a CDN in production, or write a small custom route that reads the file and sets your own headers:
// 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
},
});
},
});
Security
- Path traversal is blocked. Requests to
/../../etc/passwdreturn 404 — the resolved target must live inside the configuredpublicdirectory. - Dotfiles are served.
public/.envwould be readable as/.env. Don’t put secrets inpublic/. - Only GET and HEAD hit the static layer. POST/PUT/DELETE to a static path return 404 (no method-not-allowed leak).
When to skip static entirely
In production, static assets usually live on a CDN (Cloudflare, S3 + CloudFront, Vercel Edge). Configure your build to upload web/dist/ to the CDN, point the client at https://cdn.yourapp.com/, and leave public/ empty in the server image — Hopak’s static layer costs nothing when the directory is empty.
21. Move your source somewhere else
Goal: use src/domain/ and src/api/ instead of app/models/ and 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',
},
});
After this:
hopak generate model postwrites tosrc/domain/post.tshopak generate route posts/[id]writes tosrc/api/posts/[id].tshopak migrate new add_slugwrites tosrc/migrations/<timestamp>_add_slug.tshopak dev,hopak sync,hopak checkscan the new directories- Static files are served from
static/instead ofpublic/
All configurable paths
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
}
All paths resolve relative to the project root (where hopak.config.ts lives). Absolute paths work too — useful when mounting a shared volume in Docker:
paths: { public: '/app/static' }
Migrating an existing project
- Move files:
mv app/models src/domain,mv app/routes src/api. - Add
pathstohopak.config.ts. - Run
hopak check— it prints what it scanned and confirms model/route counts match. If anything’s wrong, the check exits with status1(great for CI). - Restart
hopak dev.
No code changes needed inside the model/route files themselves — the paths in hopak.config.ts are the only source of truth.
Gotcha: the .hopak/ runtime directory
.hopak/ holds the SQLite file (.hopak/data.db) and the dev TLS certs (.hopak/certs/). Override with paths.hopakDir if you need a different location — e.g. /var/lib/myapp in a systemd deployment. The directory is created automatically on first write.
22. Scaffold files from the CLI
Goal: don’t write boilerplate by hand. Every file Hopak uses to serve your app is generated by a single command and then edited like normal source — no runtime magic builds routes, certs, or CRUD handlers behind your back.
Four kinds: model, route, crud, cert.
generate model <name> — one table
hopak generate model comment
# → Created file app/models/comment.ts
hopak g model comment # same thing, short form
The generated model is deliberately minimal; replace the fields with your real schema:
// app/models/comment.ts
import { model, text } from '@hopak/core';
export default model('comment', {
name: text().required(),
});
Generating the model alone gives you a DB table (after hopak sync) and a typed client (ctx.db.model('comment')) — but no HTTP endpoints. Run hopak generate crud comment next to expose REST, or write your own route files.
generate crud <name> — REST for a model
hopak generate crud post
# → Created file app/routes/api/posts.ts
# → Created file app/routes/api/posts/[id].ts
Two files using the crud helpers from @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);
After the scaffold:
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
Customize any verb by replacing the corresponding export with your own defineRoute(...); delete the export to remove the verb entirely (the router answers 405 Method Not Allowed with an Allow: header listing what remains). See Recipe 5 for the full flow.
The model must exist before you run generate crud; the command only writes the route files.
generate route <path> — one 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)
Starter contents:
import { defineRoute } from '@hopak/core';
export const GET = defineRoute({
handler: (ctx) => ({ ok: true, path: ctx.path }),
});
Rename GET to any other verb, or add multiple exports to the same file for multiple methods.
generate cert — dev HTTPS key + cert
hopak generate cert
# → Generating self-signed dev certificate { path: ".hopak/certs" }
# → Dev certificate ready. Re-run `hopak dev` with HTTPS enabled.
Writes .hopak/certs/dev.key + dev.crt + a local .gitignore so the material never lands in a commit. Enable HTTPS in config (server.https.enabled: true) and restart hopak dev. If you turn on HTTPS without running this first, hopak dev refuses to start and points you back here — the runtime never fabricates crypto on its own.
See Recipe 18 for the full HTTPS walkthrough.
Path normalization
hopak generate route is forgiving about how you spell the path:
| You type | File created |
|---|---|
search | app/routes/search.ts |
/search | app/routes/search.ts (leading / stripped) |
search.ts | app/routes/search.ts (.ts stripped) |
posts/new.ts | app/routes/posts/new.ts |
Parent directories are created automatically.
Custom project paths
If hopak.config.ts has paths.models: 'src/domain', hopak generate model comment writes to src/domain/comment.ts — the generator respects the config (see Recipe 21).
Refusal policy — never overwrites
Running any generate twice against the same target path fails on the second run:
Error: File already exists: app/models/comment.ts
Exit code 1 — safe to run from npm scripts or Makefiles. Delete the file (or rename it) if you really want a fresh template. generate cert is the exception: if both dev.key and dev.crt are already present it exits 0 with Dev certificate already exists (idempotent — safe in setup scripts).
23. Log every request (with a correlation id)
Goal: one line per request in your logs, plus a correlation id you can match against client-side tickets. Both are in @hopak/core and enable in one command:
hopak use request-log
# → Patched main.ts — requestId() + requestLog() now run on every request
main.ts becomes:
import { hopak, requestId, requestLog } from '@hopak/core';
await hopak().before(requestId()).after(requestLog()).listen();
On each request you get:
GET /api/posts 200 3ms [0f4b2c…]
POST /api/auth/login 401 8ms [b1c9ae…] ! bad credentials
The id also rides back as X-Request-Id on the response so a client and server share the same tag.
Pick the format:
// 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 }) }))
Put the requestId() before any middleware that throws — any handler or middleware calling ctx.log.info(...) after it will carry the id implicitly via the request log line. Use a custom generator to swap UUIDs for ULIDs:
.before(requestId({ generate: () => someUlid() }))
24. Add JWT auth (signup, login, me, gated routes)
Goal: full credential-based auth with a working signup, login, and me endpoint in one command — plus a requireAuth() you can drop on any route.
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
Copy .env.example → .env, set JWT_SECRET (32+ random bytes — openssl rand -hex 32), then hopak sync && hopak dev. The three endpoints come up with zero extra code:
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 }
Gate any other route with requireAuth() from the generated middleware file:
// 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()] });
Role-based access
@hopak/auth ships requireRole(...names) — stack it after 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
Multiple roles are OR-of: requireRole('admin', 'editor'). Add custom claims by extending AuthUser:
// app/middleware/auth.ts
import 'app/types/auth';
// app/types/auth.ts
declare module '@hopak/auth' {
interface AuthUser {
tenantId: number;
}
}
Then pass claims: ['id', 'role', 'tenantId'] to jwtAuth({...}).
OAuth (GitHub, Google)
@hopak/auth/oauth/github and /oauth/google expose matching *Start / *Callback route handlers that share the same signToken you already have. State is verified statelessly with HMAC — no cookie store:
// 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 ?? '',
}),
});
Set GITHUB_OAUTH_ID and GITHUB_OAUTH_SECRET in .env. New users are created with { email, name, password: 'oauth:<uuid>' } by default — override with the createUser option when your model has other required fields. Google works the same way from @hopak/auth/oauth/google.
25. Evolve the schema with migrations
Goal: change a model after day 1 without losing data — with reviewable up/down, rollback, and audit trail.
hopak sync is for the dev bootstrap: it runs CREATE TABLE IF NOT EXISTS on first boot and nothing else. The moment you need to add a column, migrations take over.
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)
Fill in the 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`;
}
Apply, inspect, rollback:
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 inside up/down is the full Hopak client — data migrations (backfill a new column, rewrite rows) live in the same file as their DDL.
Transactional contract:
- SQLite / Postgres: each migration runs inside
db.transaction(). - MySQL: DDL auto-commits, so migrations run without the outer tx; the idiom is one DDL per file to keep failures recoverable.
Once app/migrations/ exists, hopak sync refuses to run — schema evolution lives in migrations exclusively. Before that point, sync is still the fastest path from hopak new to a working endpoint.
If you change a model column while still on sync, the next hopak dev prints a drift warning pointing at hopak migrate init — the natural moment to adopt migrations.