baked-orm
v0.4.1
Published
ActiveRecord-inspired ORM and migration tool for Bun. PostgreSQL-native with auto-generated typed schemas.
Downloads
643
Maintainers
Readme
baked-orm
A convention-over-configuration ORM and migration tool for Bun. TypeScript-first, PostgreSQL-native, with auto-generated typed schemas, ActiveRecord-inspired querying, validations, callbacks, and dirty tracking.
Install
bun add baked-ormSetup
Add a script alias to your package.json:
{
"scripts": {
"bake": "bake"
}
}Bun automatically reads your .env file for database configuration:
PGHOST=localhost
PGPORT=5432
PGUSER=postgres
PGPASSWORD=password
PGDATABASE=myappCommands
Initialize config (optional)
bun bake db initGenerates a baked.config.ts with default settings, pre-populated with database connection details from your environment variables. This is optional — baked-orm works with zero configuration.
Create or drop a database
bun bake db create myapp # Create the database
bun bake db drop myapp # Drop the databaseConnects to the postgres maintenance database to run CREATE DATABASE or DROP DATABASE. Uses connection details from your config or PG* env vars.
Generate a migration
bun bake db generate <migration_name>Creates a timestamped migration file at db/migrations/{timestamp}.<name>.ts.
The generator recognizes naming conventions and scaffolds contextual templates:
| Command | Generates |
|---|---|
| bun bake db generate create_enum_status | CREATE TYPE status AS ENUM (...) + DROP TYPE |
| bun bake db generate create_users | CREATE TABLE users with id, timestamps, updated_at trigger + DROP TABLE |
| bun bake db generate soft_delete_posts | ADD COLUMN discarded_at + partial index + DROP COLUMN |
| bun bake db generate update_users | ALTER TABLE users ADD COLUMN + DROP COLUMN |
| bun bake db generate alter_users | Same as update_ |
| bun bake db generate delete_users | DROP TABLE users + CREATE TABLE stub |
| bun bake db generate drop_users | Same as delete_ |
| bun bake db generate add_indexes | Blank up/down template |
Example generated file for create_users:
import type { TransactionSQL } from "bun";
export async function up(txn: TransactionSQL) {
await txn`
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`;
}
export async function down(txn: TransactionSQL) {
await txn`DROP TABLE users`;
}Run migrations
bun bake db migrate up # Run all pending migrations
bun bake db migrate up --count=1 # Run next pending migration
bun bake db migrate down # Rollback last migration
bun bake db migrate down --count=3 # Rollback last 3 migrationsAll migrations run inside a transaction with an advisory lock to prevent concurrent execution. If any step fails, the entire migration is rolled back.
Conflict detection: If two developers generate migrations with the same timestamp (same second), baked-orm detects the duplicate and throws an error before running. Rename one of the conflicting files to resolve.
Check status
bun bake db statusShows which migrations have been applied and which are pending.
Generate a model
bun bake model User # infers table "users"
bun bake model BlogPost # infers table "blog_posts"
bun bake model User --table user_accounts # explicit table nameGenerates both backend and frontend model files:
models/user.ts ← import { Model } from "baked-orm"
frontend/models/user.ts ← import { FrontendModel } from "baked-orm/frontend"Options:
| Flag | Description |
|------|-------------|
| --table <name> | Explicit table name (default: inferred) |
| --backend <path> | Override backend output directory |
| --frontend <path> | Override frontend output directory |
| --no-frontend | Skip frontend model |
| --no-backend | Skip backend model |
Output directories default to modelsPath and frontendModelsPath from baked.config.ts.
Schema file
After each migration, baked-orm introspects your database and generates a typed schema file at db/schema.ts. This file contains:
- Enum types — PostgreSQL enum types introspected from
pg_enum, generated as TypeScript string union types with runtime const arrays for validation - Row classes — typed classes with
declare'd properties matching your table columns, extendable in your own code - Table definitions — column metadata, primary keys, indexes, foreign keys, and enum values
- Composite types — Postgres composite types introspected from
pg_typeand generated as classes
ORM
baked-orm includes an ActiveRecord-inspired ORM. Define models by wrapping the generated table definitions with Model():
Setup
import { connect } from "baked-orm";
await connect(); // reads baked.config.ts, establishes connection
// Or with query logging:
await connect({
onQuery: ({ text, values, durationMs }) => {
console.log(`[${durationMs.toFixed(1)}ms] ${text}`);
},
});Define models
Each model lives in its own file. Use string-based model references with import type to avoid circular imports — TypeScript infers the association types automatically:
// models/user.ts
import { Model, hasMany } from "baked-orm";
import { users } from "../db/schema";
import type { Post } from "./post";
import type { Comment } from "./comment";
export class User extends Model(users, {
posts: hasMany<Post>("Post"),
comments: hasMany<Comment>("Comment", { as: "commentable" }),
}) {
get initials() {
return this.name.split(" ").map(word => word[0]).join("");
}
}// models/post.ts
import { Model, belongsTo, hasMany, hasManyThrough } from "baked-orm";
import { posts } from "../db/schema";
import type { User } from "./user";
import type { Comment } from "./comment";
import type { Tag } from "./tag";
export class Post extends Model(posts, {
author: belongsTo<User>("User", { foreignKey: "userId" }),
comments: hasMany<Comment>("Comment", { as: "commentable" }),
tags: hasManyThrough<Tag>("Tag", { through: "taggings" }),
}) {}// models/comment.ts — polymorphic
import { Model, belongsTo } from "baked-orm";
import { comments } from "../db/schema";
import type { Post } from "./post";
import type { User } from "./user";
export class Comment extends Model(comments, {
commentable: belongsTo<Post | User>({ polymorphic: true }),
}) {}Association types are fully inferred: user.load("posts") returns Promise<Post[]>, post.load("author") returns Promise<User | null> — no manual type declarations needed. String-based refs ("Post" instead of () => Post) resolve from the model registry at runtime, and import type ensures no circular import issues.
CRUD
// Create
const user = await User.create({ name: "Alice", email: "[email protected]" });
// Mass create
const users = await User.createMany([
{ name: "Alice", email: "[email protected]" },
{ name: "Bob", email: "[email protected]" },
]);
// Find
const found = await User.find(id); // throws RecordNotFoundError
const maybe = await User.findBy({ email }); // null if missing
// Update
await user.update({ name: "Alice Smith" });
// Save (INSERT if new, UPDATE if persisted)
const user = new User({ name: "Alice" });
await user.save();
// Destroy
await user.destroy();
// Upsert
await User.upsert(
{ email: "[email protected]", name: "Alice Updated" },
{ conflictColumns: ["email"] },
);Query builder
Chainable, immutable, and thenable — await User.where(...) executes directly:
const results = await User.where({ name: "Alice" }).order({ createdAt: "DESC" }).limit(10);
const count = await User.where({ active: true }).count();
const exists = await User.exists({ email: "[email protected]" });
// Mass operations
await User.where({ active: false }).updateAll({ deletedAt: now });
await User.where({ active: false }).deleteAll();
// Raw SQL fragments
await User.whereRaw('"age" > $1', [18]).order({ name: "ASC" });Associations
Load associations explicitly. Return types are inferred from the model definition:
const posts = await user.load("posts"); // Post[]
const author = await post.load("author"); // User | null
const target = await comment.load("commentable"); // Post | User | null
const tags = await post.load("tags"); // Tag[]Results are cached — calling load() again returns the same data without a query.
Eager loading (N+1 prevention)
const users = await User.where({ active: true }).includes("posts").toArray();
// users[0].posts is already loaded — no extra queryNested eager loading uses dotted paths:
const users = await User.all()
.includes("posts.comments", "posts.author", "profile")
.toArray();
// users[0].posts[0].comments — loaded in one query per level
// users[0].posts[0].author — also loadedDirty tracking
Only modified columns are sent in UPDATE queries. This prevents last-write-wins on concurrent requests:
const user = await User.find(id);
user.changed(); // false
user.name = "New Name";
user.changed(); // true
user.changed("name"); // true
user.changed("email"); // false
user.changedAttributes(); // { name: { was: "Old", now: "New" } }
await user.save(); // UPDATE users SET "name" = $1 WHERE "id" = $2
// Only the "name" column is sent — not all columnsSaving a persisted record with no changes skips the UPDATE entirely.
JSON/JSONB columns
The generated schema types json/jsonb columns as unknown. Narrow the type with declare on your model:
// Define the shape of your JSON column
interface UserSettings {
theme: "light" | "dark";
notifications: { email: boolean; push: boolean };
}
// Generated schema (don't edit):
// export class UsersRow {
// declare id: string;
// declare settings: unknown; // ← jsonb
// }
// Your model — narrow the type:
class User extends Model(users) {
declare settings: UserSettings;
}
// Now fully typed:
user.settings.theme; // "light" | "dark"
user.settings.notifications.email; // booleanDirty tracking works for in-place mutations of JSON columns — no need to replace the entire object:
const user = await User.find(id);
user.settings.theme = "dark"; // mutate in place
user.changed("settings"); // true — detected via deep comparison
await user.save(); // UPDATE users SET "settings" = $1 WHERE "id" = $2This uses structuredClone on capture and Bun.deepEquals on comparison. For very large JSON blobs, replacing the reference (user.settings = { ...newValue }) avoids the deep comparison cost.
The same pattern works with frontend models — FrontendModel shares the same Snapshot engine:
// frontend/models/user.ts
class User extends FrontendModel(users) {
declare settings: UserSettings;
}
const user = User.fromJSON(apiResponse);
user.settings.theme = "dark";
user.changed("settings"); // trueTransactions
All queries inside a transaction() block automatically use the same connection:
import { transaction } from "baked-orm";
await transaction(async () => {
const user = await User.create({ name: "Alice" });
await Post.create({ title: "Hello", userId: user.id });
// Auto-rollback on any error
});Batch processing
Process large tables without loading everything into memory:
// Iterate one record at a time, fetched in batches of 1000 (default)
await User.where({ active: true }).findEach(async (user) => {
await sendEmail(user.email);
}, { batchSize: 1000 });
// Or work with batches directly
await User.all().findInBatches(async (batch) => {
await bulkIndex(batch);
}, { batchSize: 500 });Both use cursor-based pagination (keyset pagination on the primary key) — safe for large tables and concurrent modifications.
Validations
Declare field-level validations as static properties — Rails-style, with structured error handling:
import { Model, validates, validate } from "baked-orm";
class User extends Model(users, {
posts: hasMany<Post>("Post"),
}) {
static validations = {
name: validates("presence"),
email: [
validates("presence"),
validates("email"),
validates("length", { maximum: 255 }),
],
age: validates("numericality", { greaterThanOrEqualTo: 0, integer: true }),
role: validates("inclusion", { in: ["admin", "user", "moderator"] }),
};
// Record-level custom validations
static customValidations = [
validate((record) => {
if (record.name === record.email) {
return { name: "must be different from email" };
}
}),
];
}Built-in validators: presence, length, numericality, format, inclusion, exclusion, email.
All validators accept message?, on?: "create" | "update", and if?: (record) => boolean for conditional execution.
// Conditional: only on create
validates("presence", { on: "create" })
// Conditional: only when a condition is met
validates("presence", { if: (record) => record.role === "admin" })Register your own reusable validators:
import { defineValidator, validates } from "baked-orm";
defineValidator("companyEmail", (value, record, options) => {
if (typeof value !== "string" || !value.endsWith("@company.com")) {
return options.message ?? "must be a company email address";
}
});
// Use like any built-in
class Employee extends Model(employees) {
static validations = {
email: validates("companyEmail"),
};
}Validation errors are structured and inspectable:
import { ValidationError } from "baked-orm";
try {
await user.save();
} catch (error) {
if (error instanceof ValidationError) {
error.errors.get("email"); // ["is not a valid email address"]
error.errors.fullMessages(); // ["Email is not a valid email address"]
error.errors.toJSON(); // { email: ["is not a valid email address"] }
}
}
// Or check without throwing:
if (!await user.isValid()) {
console.log(user.errors.fullMessages());
}Note: Bulk operations (createMany, upsertAll, updateAll, deleteAll) skip validations and callbacks for performance.
Enum support
PostgreSQL enum types are first-class citizens. After running migrations, the generated schema includes typed enums:
// Generated in db/schema.ts
export type Status = "active" | "inactive" | "archived";
export const StatusValues = ["active", "inactive", "archived"] as const;
export class UsersRow {
declare id: string;
declare status: Status;
}
// Column definition includes enumValues for runtime validation
// status: { type: "USER-DEFINED", nullable: false, columnName: "status", enumValues: StatusValues },Enum columns are auto-validated — no need to manually declare validates("inclusion"). Invalid values produce clear error messages:
const user = new User({ status: "deleted" });
await user.isValid(); // false
user.errors.get("status"); // ["is not a valid value (must be one of: active, inactive, archived)"]Generate an enum migration:
bun bake db generate create_enum_statusSoft deletes (discard pattern)
Opt-in soft deletes that don't override destroy() and don't add default scopes — inspired by Ruby's discard gem:
class Post extends Model(posts) {
static softDelete = true;
}
// Soft delete — sets discarded_at, does NOT delete the row
await post.discard();
post.isDiscarded; // true
post.isKept; // false
// Restore
await post.undiscard();
// Hard delete — still works, actually removes the row
await post.destroy();Query scopes are explicit — no default scope, no hidden WHERE clauses:
// All records (including discarded)
await Post.all();
// Only non-discarded records
await Post.kept();
await Post.kept().where({ authorId: user.id }).order({ createdAt: "DESC" });
// Only discarded records
await Post.discarded();
// Bulk operations (skip callbacks)
await Post.where({ authorId: user.id }).discardAll();
await Post.discarded().undiscardAll();Lifecycle callbacks:
class Post extends Model(posts) {
static softDelete = true;
static beforeDiscard = [(record) => {
console.log(`Discarding post ${record.id}`);
}];
static afterUndiscard = [(record) => {
console.log(`Restored post ${record.id}`);
}];
}Generate a migration to add the discarded_at column to an existing table:
bun bake db generate soft_delete_postsCallbacks
Lifecycle callbacks are declared as static arrays on the model class:
class User extends Model(users) {
static beforeSave = [(record) => {
record.email = record.email.toLowerCase();
}];
static afterCreate = [async (record) => {
await AuditLog.create({ action: "user_created", userId: record.id });
}];
static beforeDestroy = [async (record) => {
await record.load("posts");
}];
}Available hooks (in execution order):
Save: beforeValidation → validations → afterValidation → beforeSave → beforeCreate/beforeUpdate → SQL → afterCreate/afterUpdate → afterSave
Destroy: beforeDestroy → SQL → afterDestroy
Discard: beforeDiscard → SQL → afterDiscard
Undiscard: beforeUndiscard → SQL → afterUndiscard
If a before* callback throws, the operation aborts.
Serialization & frontend hydration
baked-orm supports a full server-to-client data pipeline: serialize models to JSON on the backend, hydrate them into typed frontend model instances on the client. Every serialized object includes a __typename field (like GraphQL) so the frontend knows which model to hydrate into.
1. Backend: define models with sensitive fields
Sensitive fields are excluded from serialization and redacted in query logs — passwords never leak to the client or into your log files:
// models/user.ts (server)
import { Model, hasMany } from "baked-orm";
import { users } from "../db/schema";
import type { Post } from "./post";
export class User extends Model(users, { posts: hasMany<Post>("Post") }) {
static sensitiveFields = ["passwordDigest"];
}2. Backend: serialize for the API response
toJSON() includes all non-sensitive columns plus __typename. For associations and field control, use serialize() with Rails-style options:
// API handler
const user = await User.find(id);
await user.load("posts");
// Default — all non-sensitive columns + __typename
user.toJSON();
// → { __typename: "User", id: "...", name: "...", email: "...", createdAt: Date }
// With associations
user.serialize({ include: ["posts", "posts.comments"] });
// Column filtering + nested association options
user.serialize({
only: ["id", "name", "email"],
include: {
posts: { only: ["id", "title"], include: { comments: { except: ["spam"] } } }
}
});3. Frontend: define models and register them
Import from baked-orm/frontend — a lightweight entrypoint with no server dependencies. Frontend models share the same db/schema.ts table definitions and support dirty tracking, validations, and hydration:
// models/user.ts (client)
import { FrontendModel, registerModels, validates } from "baked-orm/frontend";
import { users, posts } from "../db/schema";
class User extends FrontendModel(users) {
static validations = { name: validates("presence"), email: validates("email") };
declare posts: Post[];
}
class Post extends FrontendModel(posts) {
declare author: User;
}
// Register once at app startup so hydrate() can resolve __typename
registerModels(User, Post);4. Frontend: hydrate API responses
fromJSON() / hydrate() automatically converts date strings to Temporal.Instant, resolves nested associations via __typename, and marks instances as persisted:
const data = await fetch("/api/users/1").then(r => r.json());
const user = User.fromJSON(data);
user.createdAt; // Temporal.Instant (auto-converted from ISO string)
user.posts[0]; // Post instance (not a plain object)
user.isNewRecord; // false (came from server)5. Frontend: forms with dirty tracking and validation
// Track changes for forms
user.name = "Updated";
user.changed("name"); // true
user.changedAttributes(); // { name: { was: "Old", now: "Updated" } }
// Validate before submitting
user.name = "";
user.isValid(); // false
user.errors.fullMessages(); // ["Name can't be blank"]
// Serialize back for the API request
user.toJSON();
// → { __typename: "User", id: "...", name: "", email: "...", createdAt: Temporal.Instant }camelCase convention
All snake_case DB column names are automatically converted to camelCase in generated Row classes. You never write user_id — always userId. The actual DB column name is stored in ColumnDefinition.columnName for the query builder to translate back.
Configuration
By default, baked-orm uses:
- Migrations path:
./db/migrations - Schema path:
./db/schema.ts - Models path:
./models - Frontend models path:
./frontend/models - Database: Bun's built-in SQL driver (reads from
PG*env vars)
Override with baked.config.ts:
import { defineConfig } from "baked-orm";
// Connect with a URL:
export default defineConfig({
database: Bun.env.POSTGRES_URL ?? Bun.env.DATABASE_URL,
});
// Or with individual options:
export default defineConfig({
migrationsPath: "./db/migrations",
schemaPath: "./db/schema.ts",
modelsPath: "./models",
frontendModelsPath: "./frontend/models",
database: {
hostname: Bun.env.PGHOST,
port: Number(Bun.env.PGPORT),
username: Bun.env.PGUSERNAME ?? Bun.env.PGUSER,
password: Bun.env.PGPASSWORD,
database: Bun.env.PGDATABASE,
},
});If database is omitted, Bun's default SQL driver is used (reads PG* env vars from .env).
Connection pool
When using object-style database configuration, you can tune the connection pool:
export default defineConfig({
database: {
hostname: "localhost",
database: "myapp",
max: 20, // Max connections (default: 10)
idleTimeout: 30, // Seconds before closing idle connections (default: 0)
maxLifetime: 3600, // Max connection lifetime in seconds (default: 0)
connectionTimeout: 10, // Seconds to wait for a connection (default: 30)
},
});Pool options are passed directly to Bun's SQL driver. URL-style database strings use Bun's defaults.
Development
bun install
# Integration tests require a local PostgreSQL database
bun bake db create baked_orm_test
bun test # run tests
bun run check # biome + knip + tsc
bun run format # auto-fix lint issuesEditor setup
For SQL syntax highlighting inside template literals, install the SQL tagged template literals VS Code extension. It highlights SQL in tagged templates like txn`SELECT * FROM users`.
License
MIT
