Models
One file defines the table, the row type, the validator.
A model is one file. It defines the table, the validation, the TypeScript row type, and (optionally) the REST endpoints.
// app/models/post.ts
import { model, text, boolean, belongsTo } from '@hopak/core';
export default model('post', {
title: text().required().min(3).max(200),
content: text().required(),
published: boolean().default(false),
author: belongsTo('user'),
});
Field types
| Type | Notes |
|---|---|
text() | Free-form string (use .min/.max/.pattern to constrain) |
email() | String with email-format validation |
url() | String with URL-format validation |
phone() | String — no built-in regex; add .pattern(...) for strict formats |
number(), money() | Numbers with min/max (money stored as real) |
boolean() | Scalar |
date(), timestamp() | Coerced from ISO strings; rejects invalid dates |
enumOf('a', 'b') | TypeScript literal union, DB enum |
json<T>() | Typed JSON column |
belongsTo('user'), hasOne('profile'), hasMany('post') | Relations |
password(), secret(), token() | Auto-excluded from JSON responses |
file(), image() | Stored as JSON metadata { url, mimeType, size, name? } |
Modifiers
Chain on any field:
text().required().min(3).max(200).unique().index()
number().required().min(0).max(100).default(0)
text().pattern(/^[a-z]+$/)
date().default('now')
file().maxSize('5MB')
image().maxSize(2_097_152) // bytes also accepted
.required()/.optional()— presence.unique()/.index()— DB constraints / indexes.min(n)/.max(n)— string length or number range.default(value)— default value (special token'now'fordate/timestamp).pattern(regex)— regex constraint on strings.maxSize(n)— max bytes onfile()/image(); accepts'5MB'/'500KB'/ raw bytes.onUpdate('now')— ondate/timestamp; currently a seed-on-create helper (same as.default('now')). DB-levelON UPDATEis not emitted yet — rely ontimestamps: truefor automaticupdatedAtrefresh.
Options
model('post', { /* fields */ }, {
timestamps: true, // add createdAt + updatedAt columns (default: true)
});
Lifecycle hooks
Added in 1.0
Hooks run around single-row writes, wherever they come from — CRUD endpoints or your own ctx.db.model(...) calls. before* hooks may return a replacement payload; after* hooks are for side effects:
// app/models/user.ts
import { model, text, email, password } from '@hopak/core';
export default model('user', {
name: text().required(),
email: email().required().unique(),
password: password().required().min(8),
}, {
hooks: {
async beforeCreate(data) {
return { ...data, password: await Bun.password.hash(String(data.password)) };
},
afterCreate(row) {
console.log(`user #${row.id} signed up`);
},
},
});
Available: beforeCreate, afterCreate, beforeUpdate(data, id), afterUpdate, beforeDelete(id), afterDelete(id). Bulk operations (createMany / updateMany / deleteMany) skip hooks — they translate to a single SQL statement.
Hashing passwords? Use
hashPasswordfrom@hopak/authrather thanBun.password.hashdirectly. It skips values that already are hashes, so a hook andcredentialsSignupcan both run without double-hashing — a double hash never matches at login.
Table names
Added in 1.0
The physical table is the pluralized model name (post → posts), available as post.tableName. You need it whenever you write SQL by hand — see Database → Table names in raw SQL.