Repository#

BaseRepository provides a standard CRUD interface with lifecycle hooks for any supported ORM. Available as separate packages:

PackageInstall
@buntok/prismabun add @buntok/prisma
@buntok/drizzlebun add @buntok/drizzle
@buntok/typeormbun add @buntok/typeorm

CRUD Methods#

MethodDescription
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:

HookDescription
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:

typescript
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:

typescript
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
Warning
$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:

typescript
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;
}
Info
The as const assertion is required to preserve literal types. Without it, TypeScript infers string[] which is not assignable to readonly (keyof T)[].

Prisma#

typescript
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#

typescript
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#

typescript
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:

typescript
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:

typescript
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#

typescript
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)#

typescript
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);
Info
For complex business logic, skip BaseController and write custom controller methods with validation, auth, etc.

Comparison#

FeaturePrismaDrizzleTypeORM
CRUD methods✅✅✅
Lifecycle hooks✅❌❌
$hidden / $visible✅✅✅
ORM accessorthis.delegatethis.dbthis.dataSource
Multi-table queriesVia delegateVia this.db.queryVia dataSource.getRepository