Repository#
BaseRepository provides a standard CRUD interface with lifecycle hooks for any supported ORM. Available as separate packages:
| Package | Install |
|---|---|
| @buntok/prisma | bun add @buntok/prisma |
| @buntok/drizzle | bun add @buntok/drizzle |
| @buntok/typeorm | bun add @buntok/typeorm |
CRUD Methods#
| Method | Description |
|---|---|
| findAll() | Get all records |
| findById(id) | Find by primary key |
| findOne(where) | Find first matching record |
| create(data) | Create a new record |
| createMany(data[]) | Bulk create |
| update(id, data) | Update by primary key |
| delete(id) | Delete by primary key |
| count() | Count all records |
| exists(where) | Check if record exists |
Lifecycle Hooks#
Prisma-based repos support hooks that run before/after each operation. Override them in your subclass:
| Hook | Description |
|---|---|
| beforeCreate(data) | Modify or validate data before insert. Return modified data or throw to abort. |
| afterCreate(result) | Run side effects after insert (logging, notifications, etc.) |
| beforeUpdate(id, data) | Modify data before update. Return modified data or throw. |
| afterUpdate(id, result) | Run side effects after update. |
| beforeDelete(id) | Validate before delete. Throw to abort. |
| afterDelete(id) | Run side effects after delete. |
Field Sanitization#
Automatically exclude or include specific fields from all CRUD responses. Configure once in your repository - every findAll(), findById(), create(), etc. will apply the rules automatically.
$hidden (Blacklist)
Exclude sensitive fields like passwords, tokens, or internal data from all responses:
interface User {
id: string;
name: string;
email: string;
passwordHash: string;
resetToken: string | null;
createdAt: Date;
}
// Prisma
class UserRepository extends BaseRepository<User, PrismaClient> {
protected override $hidden = ["passwordHash", "resetToken"] as const;
constructor(prisma: PrismaClient) {
super(prisma, "user");
}
}
// Drizzle
class UserRepository extends BaseRepository<typeof users, CreateInput, UpdateInput> {
protected override $hidden = ["passwordHash", "resetToken"] as const;
}
// TypeORM
class UserRepository extends BaseRepository<User, CreateInput, UpdateInput> {
protected override $hidden = ["passwordHash", "resetToken"] as const;
}
// Now ALL responses automatically exclude passwordHash and resetToken:
await userRepo.findAll(); // no passwordHash, no resetToken
await userRepo.findById("1"); // same
await userRepo.create(data); // same
await userRepo.update(id, data);// same$visible (Whitelist)
Only return specific fields. Takes precedence over $hidden:
class UserRepository extends BaseRepository<User, PrismaClient> {
// Only return id, name, email - everything else is excluded
protected override $visible = ["id", "name", "email"] as const;
constructor(prisma: PrismaClient) {
super(prisma, "user");
}
}
// Response: { id: "1", name: "Budi", email: "budi@test.com" }
// passwordHash, resetToken, createdAt are all excluded$visible takes precedence over $hidden. If both are set, only $visible rules are applied.Type Safety#
Both $hidden and $visible are type-safe - TypeScript will catch typos and non-existent fields at compile time:
class UserRepository extends BaseRepository<User, PrismaClient> {
// ✅ Correct
protected $hidden = ["passwordHash"] as const;
// ❌ Compile error: "passwordHaash" is not assignable to keyof User
// Did you mean "passwordHash"?
protected $hidden = ["passwordHaash"] as const;
// ❌ Compile error: "typo_field" is not assignable to keyof User
protected $visible = ["id", "typo_field"] as const;
}as const assertion is required to preserve literal types. Without it, TypeScript infers string[] which is not assignable to readonly (keyof T)[].Prisma#
import type { PrismaClient } from "../prisma/generated/client";
import type { Prisma } from "../prisma/generated/client";
import { BaseRepository } from "@buntok/prisma";
interface User {
id: number;
name: string;
email: string;
}
// Simple usage
class UserRepository extends BaseRepository<User, PrismaClient> {
constructor(prisma: PrismaClient) {
super(prisma, "user");
}
async findByEmail(email: string) {
return this.delegate.findUnique({ where: { email } });
}
}
// With hooks
class UserRepository extends BaseRepository<User, PrismaClient, Prisma.UserCreateInput, Prisma.UserUpdateInput> {
constructor(prisma: PrismaClient) {
super(prisma, "user");
}
protected override async beforeCreate(data: Prisma.UserCreateInput) {
data.email = data.email.toLowerCase();
return data;
}
protected override async afterCreate(user: User) {
console.log("User created:", user.id);
}
}Drizzle#
import { drizzle } from "drizzle-orm/bun-sqlite";
import { BaseRepository } from "@buntok/drizzle";
import { users } from "./db/schema";
import type { DrizzleDB } from "./db";
interface CreateUserInput {
name: string;
email: string;
}
interface UpdateUserInput {
name?: string;
email?: string;
}
class UserRepository extends BaseRepository<typeof users, CreateUserInput, UpdateUserInput, DrizzleDB> {
constructor(db: DrizzleDB) {
super(db, users);
}
async findWithPosts() {
return this.db.query.users.findMany({
with: { posts: true }
});
}
}TypeORM#
import { DataSource } from "typeorm";
import { BaseRepository } from "@buntok/typeorm";
import { User } from "./entities/user";
import { Post } from "./entities/post";
interface CreateUserInput {
name: string;
email: string;
}
interface UpdateUserInput {
name?: string;
email?: string;
}
class UserRepository extends BaseRepository<User, CreateUserInput, UpdateUserInput> {
constructor(dataSource: DataSource) {
super(dataSource, User);
}
async findWithPosts() {
return this.dataSource.getRepository(User).find({
relations: ["posts"]
});
}
}Full Stack: Auto CRUD#
Combine BaseRepository with BaseController to get a complete CRUD API with zero boilerplate. BaseController auto-registers 5 routes:GET /, GET /:id, POST /, PUT /:id, DELETE /:id.
Service Layer#
Use BaseService to avoid writing boilerplate. It adapts repository method names to match BaseController's expected interface:
import { BaseService } from "@buntok/core";
import { UserRepository } from "@/repositories/user.repository";
import type { User } from "@prisma/client";
export class UserService extends BaseService<User> {
constructor(private readonly userRepository: UserRepository) {
super(userRepository);
}
}
// BaseService provides these methods automatically:
// getAll() → repository.findAll()
// getById(id) → repository.findById(id) + NotFoundError
// create(data) → repository.create(data)
// update(id, data) → repository.update(id, data)
// delete(id) → repository.delete(id)
// count() → repository.count()Overriding Service Methods#
Override any method to add custom business logic:
import { BaseService } from "@buntok/core";
import { NotFoundError } from "@buntok/core";
import { UserRepository } from "@/repositories/user.repository";
import type { User } from "@prisma/client";
export class UserService extends BaseService<User> {
constructor(private readonly userRepository: UserRepository) {
super(userRepository);
}
// Override getAll - add filtering, sorting, etc.
async getAll(): Promise<User[]> {
const users = await this.userRepository.findAll();
return users.filter((u) => u.active);
}
// Override getById - custom error message
async getById(id: string | number): Promise<User> {
const user = await this.userRepository.findById(id);
if (!user) throw new NotFoundError("User not found");
return user;
}
// Override create - add validation or side effects
async create(data: any): Promise<User> {
const existing = await this.userRepository.findByEmail(data.email);
if (existing) throw new Error("Email already taken");
return super.create(data);
}
// Override delete - soft delete instead of hard delete
async delete(id: string | number): Promise<User> {
return this.userRepository.update(id, { deletedAt: new Date() });
}
}Controller#
import { Controller } from "@buntok/core";
import { BaseController } from "@buntok/core";
@Controller("/users")
class UserController extends BaseController<User> {
constructor(private userService: UserService) {
super(userService);
}
}
// That's it! These routes are automatically registered:
// GET /users → userService.getAll()
// GET /users/:id → userService.getById(id)
// POST /users → userService.create(body)
// PUT /users/:id → userService.update(id, body)
// DELETE /users/:id → userService.delete(id)Complete Example (Prisma)#
import { Buntok } from "@buntok/core";
import { BaseController, BaseService } from "@buntok/core";
import { BaseRepository } from "@buntok/prisma";
import { prisma } from "@/lib/prisma";
import type { User, PrismaClient, Prisma } from "@prisma/client";
// 1. Repository - data access
class UserRepository extends BaseRepository<
User, PrismaClient, Prisma.UserCreateInput, Prisma.UserUpdateInput
> {
constructor() {
super(prisma, "user");
}
}
// 2. Service - business logic
class UserService extends BaseService<User> {
constructor(private readonly userRepository: UserRepository) {
super(userRepository);
}
}
// 3. Controller - auto CRUD routes
@Controller("/users")
class UserController extends BaseController<User> {
constructor(private readonly userService: UserService) {
super(userService);
}
}
// 4. Register
const app = new Buntok();
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
app.registerController(new UserController(userService));
app.listen(1212);BaseController and write custom controller methods with validation, auth, etc.Comparison#
| Feature | Prisma | Drizzle | TypeORM |
|---|---|---|---|
| CRUD methods | ✅ | ✅ | ✅ |
| Lifecycle hooks | ✅ | ❌ | ❌ |
$hidden / $visible | ✅ | ✅ | ✅ |
| ORM accessor | this.delegate | this.db | this.dataSource |
| Multi-table queries | Via delegate | Via this.db.query | Via dataSource.getRepository |