Upgrading
Version-to-version migration guides for Hopak.js.
Each minor release of @hopak/core bumps the public surface in a controlled way. This page collects the migration steps — latest first. Pick the section that matches the version you are on today.
If you are starting fresh, skip this page entirely and follow the Quick start.
0.4.x to 0.5
@hopak/core@0.5.0 reshapes the Database escape hatches. .model() still covers the 90% typed path; the other two slots are now sharper:
db.sql— new. Tagged-template SQL for the ~5% of queries that don’t fit.model(): window functions, recursive CTEs, vendor JSON / FTS,EXPLAIN ANALYZE. Interpolations are parameterised, sosql`SELECT * FROM post WHERE id = ${id}`is safe — SQL injection via${...}is fundamentally impossible with this shape. Typed return:db.sql<Post>`...`resolves toPromise<readonly Post[]>.db.builder()— renamed fromdb.raw(). Same return type (the dialect’s Drizzle client); clearer name. For the <1% that needs query-builder composition beyond what.sqloffers.db.execute(sql, params?)— marked@deprecated. Still works in 0.5.x; will be removed in 0.6.0. New code should usedb.sql.
The escape hierarchy is now flat:
model() ← 90% of queries
↓
db.sql`...` ← raw SQL with types (≈5% escape)
↓
db.builder() ← Drizzle query builder (<1% escape)
Migration steps
-
Rename
db.raw()→db.builder(). Same return type, no behaviour change. Search-and-replace is safe.// before const drizzle = db.raw() as BunSQLiteDatabase; // after const drizzle = db.builder() as BunSQLiteDatabase; -
Prefer
db.sqloverdb.execute(...)for new code. The forwarder stays, so existing calls compile unchanged, but new work should adopt the tagged template.// before await db.execute('UPDATE post SET published = ? WHERE id = ?', [true, id]); // after await db.sql`UPDATE post SET published = ${true} WHERE id = ${id}`; // for reads (new capability) const rows = await db.sql<{ day: string; n: number }>` SELECT DATE(created_at) AS day, COUNT(*) AS n FROM post WHERE published = true GROUP BY day ORDER BY day DESC `; -
Migration files: prefer
ctx.sql.hopak migrate newnow scaffoldsctx.sqlby default. Existing migrations usingctx.execute(...)keep running untouched — no migration rewrite is needed before 0.6.0.// new scaffolded migrations export async function up(ctx: MigrationContext): Promise<void> { await ctx.sql`ALTER TABLE post ADD COLUMN views INTEGER NOT NULL DEFAULT 0`; }
What changed under the hood
db.raw() always returned the dialect’s Drizzle client typed as unknown. Callers cast it to { all?, execute? } and branched per dialect to run a single SELECT — the framework’s own tracker.ts did this for the _hopak_migrations read. The new db.sql is driver-native: a pure tag-to-SQL compiler turns ${value} interpolations into dialect-appropriate placeholders (? or $N), then the statement goes straight to bun:sqlite.prepare().all(), postgres.js sql.unsafe(), or mysql2.pool.execute() — no Drizzle on the read path. Inside a transaction, Postgres and MySQL still route through Drizzle’s tx handle (the native connection isn’t reachable from there), but the shape is stable within a Drizzle major and covered by tests.
The rename from raw to builder is cosmetic but clarifies intent: .builder() returns a builder, not a blob of raw SQL capability. .sql is the raw-SQL primitive now.
0.3.x to 0.4
@hopak/core@0.4.0 swaps the validation runtime from Zod to Valibot — ~10× smaller bundle, ~2–3× faster parse, same validate() / buildModelSchema() API. Code using only Hopak’s model-driven validation keeps working untouched. Breaking only for projects that imported Zod directly in route files.
What actually changes
-
@hopak/coreno longer depends onzod. If your own project code importedzodtransitively through@hopak/core, add it to your ownpackage.json. -
RouteSchemas.body | query | paramstypes are now Valibot schemas (v.GenericSchema), notz.ZodType. Route files that passed Zod schemas directly need to switch to Valibot:// before import { z } from 'zod'; export const post: RouteHandler = { body: z.object({ title: z.string().min(3) }), handler: async (ctx) => { /* ... */ }, }; // after import * as v from 'valibot'; export const post: RouteHandler = { body: v.object({ title: v.pipe(v.string(), v.minLength(3)) }), handler: async (ctx) => { /* ... */ }, }; -
Error messages from validators use Valibot’s defaults. If your tests assert on exact message text, re-snapshot.
-
ZodFieldSchematype export renamed toFieldSchema.
To adopt
bun add valibot in your project, rewrite any hand-rolled schemas in route files using v.* primitives. Model schemas built via buildModelSchema(model) need no changes.
What changed under the hood
Valibot is a tree-shakeable, pipe-based validator; every rule is a standalone function you compose with v.pipe. That’s why the bundle shrinks: if you only use v.string, that’s all you ship. The API shape differs from Zod (z.string().min(3) → v.pipe(v.string(), v.minLength(3))) but the semantics line up. See Validation and Errors for the current error shape.
0.2.x to 0.3
@hopak/core@0.3.0 adds migrations. hopak sync still works for greenfield projects, but its role shifts — it’s now the dev bootstrap path, not the schema-evolution path. Non-breaking if you don’t adopt migrations; once you do, the rules below apply.
What changes if you’re on 0.2.x
hopak syncnow refuses to run (exit1) onceapp/migrations/has any.tsfiles. The message points you athopak migrate up. If you never add migrations,syncbehaves exactly as before.hopak dev’s boot-timedb.sync()is skipped whenapp/migrations/has files. With migrations in place, the runtime never alters the schema on its own — you control every change.hopak syncnow prints a drift warning when a model declares columns the live DB doesn’t have. This is informational; the warning points you athopak migrate initto start tracking..index()on a field now actually createsCREATE INDEX IF NOT EXISTSduring sync. It was silently ignored in 0.2.x — if you’d added.index()expecting an index, re-runhopak syncto pick it up. (No-op if the index already exists from other means.)- New public API:
ctx.db.execute(sql, params?)for raw SQL — mainly for migration files, but available everywhere.
To adopt migrations on an existing 0.2.x project
hopak migrate init # captures the current model state
hopak migrate up # writes _hopak_migrations; no schema change
From here, every model change starts with hopak migrate new <name>.
Nothing breaks if you don’t want migrations yet. Keep using hopak sync until schema evolution (adding a column to an existing table) forces the move. See Migrations for the full walkthrough.
What changed under the hood
Before 0.3, schema state was computed at boot: models were the source of truth and db.sync() coerced the database to match. That’s great for the first week and painful by the second — there’s no history and no way to roll back a column rename. 0.3 introduces an explicit ledger (_hopak_migrations) plus per-migration up/down functions. Sync still owns the zero-to-one moment; migrations own every step after.
0.1.x to 0.2
@hopak/core@0.2.0 is a breaking release. Nothing materializes at runtime from a declaration any more — CRUD endpoints and dev certs are scaffolded by the CLI, and the runtime just executes whatever is in your files.
Migration steps
- Upgrade the CLI:
bun add -g @hopak/cli@latest. - Upgrade the framework:
bun add @hopak/core@latest @hopak/testing@latestin your project. - For every model that had
{ crud: true }, run once:
That writeshopak generate crud <model-name>app/routes/api/<plural>.tsandapp/routes/api/<plural>/[id].tswith the same six verbs the runtime used to inject. Remove{ crud: true }from the model file (it’s just a type error now, no behavioral effect):// before export default model('post', { ... }, { crud: true }); // after export default model('post', { ... }); - If you had
server.https.enabled: true, runhopak generate certonce. Boot no longer calls openssl behind your back — if the cert files aren’t there,hopak devfails fast with a pointer to this command. - If you used
@hopak/testing’screateTestServer({ withCrud: true }), switch to wiring routes via the newcrud.*helpers (or passrootDirto test the project end-to-end). See@hopak/testing’s README.
Also removed
These existed on the type but were never wired to anything — deleting is mechanical:
ModelOptions.ownerModelOptions.publicReadModelOptions.authModelOptions.softDelete
Nothing else in the public surface changed — models, relations, query ergonomics, validation, serialization, errors, HTTPS / CORS config, hopak use, hopak sync, hopak check all behave exactly as before.
What changed under the hood
0.2.0 moved CRUD from a runtime feature to a scaffolded one. Instead of the framework synthesizing route handlers from model options at boot, the CLI writes real .ts files you can read, edit, and extend. Same for dev HTTPS certs: explicit files on disk, no implicit openssl invocations. The runtime shrinks; what you see in your tree is what runs.