Skip to main content

Examples

Complete working examples showing how to build applications with BunSane.

Basic Todo Application

A simple todo list API demonstrating components, archetypes, and services.

Project Structure

todo-api/
src/
components/
TodoComponent.ts
archetypes/
TodoArcheType.ts
services/
TodoService.ts
App.ts
index.ts

Component Definition

src/components/TodoComponent.ts
import { BaseComponent, CompData, Component } from "bunsane/core/components";

@Component
export class TodoTag extends BaseComponent {}

@Component
export class TodoInfoComponent extends BaseComponent {
@CompData()
title: string = "";

@CompData()
description: string = "";

@CompData()
completed: boolean = false;

@CompData({ indexed: true })
createdAt: Date = new Date();
}

Archetype Definition

src/archetypes/TodoArcheType.ts
import {
ArcheType,
ArcheTypeField,
BaseArcheType,
type ArcheTypeOwnProperties,
} from "bunsane/core/ArcheType";
import { TodoTag, TodoInfoComponent } from "../components/TodoComponent";

@ArcheType("Todo")
export class TodoArcheTypeClass extends BaseArcheType {
@ArcheTypeField(TodoTag)
tag!: TodoTag;

@ArcheTypeField(TodoInfoComponent)
info!: TodoInfoComponent;
}

export type ITodoArcheType = ArcheTypeOwnProperties<TodoArcheTypeClass>;
export const TodoArcheType = new TodoArcheTypeClass();

Service Definition

src/services/TodoService.ts
import { BaseService } from "bunsane/service";
import { GraphQLOperation } from "bunsane/gql";
import App from "bunsane/core/App";
import { Entity } from "bunsane/core/Entity";
import { Query } from "bunsane/query";
import type { GraphQLContext } from "bunsane/types/graphql.types";
import { z } from "zod";
import { TodoArcheType } from "../archetypes/TodoArcheType";
import { TodoTag, TodoInfoComponent } from "../components/TodoComponent";

class TodoService extends BaseService {
constructor(private app: App) {
super();
TodoArcheType.registerFieldResolvers(this);
}

@GraphQLOperation({
type: "Query",
output: TodoArcheType,
})
async getTodo(args: { id: string }, context: GraphQLContext) {
return await Entity.FindById(args.id);
}

@GraphQLOperation({
type: "Query",
output: [TodoArcheType],
})
async listTodos(args: {}, context: GraphQLContext) {
return await new Query()
.with(TodoTag)
.with(TodoInfoComponent)
.sortBy(TodoInfoComponent, "createdAt", "DESC")
.exec();
}

@GraphQLOperation({
type: "Mutation",
input: z.object({
title: z.string(),
description: z.string(),
}),
output: TodoArcheType,
})
async createTodo(
args: { title: string; description?: string },
context: GraphQLContext
) {
const todo = Entity.Create()
.add(TodoTag, {})
.add(TodoInfoComponent, {
title: args.title,
description: args.description || "",
completed: false,
createdAt: new Date(),
});

await todo.save();
return todo;
}

@GraphQLOperation({
type: "Mutation",
input: z.object({
id: z.string(),
title: z.string(),
description: z.string(),
completed: z.boolean(),
}),
output: TodoArcheType,
})
async updateTodo(
args: { id: string; title?: string; description?: string; completed?: boolean },
context: GraphQLContext
) {
const todo = await Entity.FindById(args.id);
if (!todo) throw new Error("Todo not found");

const currentInfo = await todo.get(TodoInfoComponent);
await todo.set(TodoInfoComponent, {
...currentInfo,
...(args.title !== undefined && { title: args.title }),
...(args.description !== undefined && { description: args.description }),
...(args.completed !== undefined && { completed: args.completed }),
});

await todo.save();
return todo;
}

@GraphQLOperation({
type: "Mutation",
input: z.object({ id: z.string() }),
output: z.boolean(),
})
async deleteTodo(args: { id: string }, context: GraphQLContext) {
const todo = await Entity.FindById(args.id);
if (!todo) return false;
await todo.delete();
return true;
}
}

export default TodoService;

Application Setup

src/App.ts
import App from "bunsane/core/App";
import { ServiceRegistry } from "bunsane/service";

import "./components/TodoComponent";
import TodoService from "./services/TodoService";

export default class TodoAPI extends App {
constructor() {
super("TodoAPI", "1.0.0");
ServiceRegistry.registerService(new TodoService(this));
}
}
index.ts
import TodoAPI from "./src/App";

const app = new TodoAPI();
app.init();

User Authentication with REST

An example showing REST endpoints for user registration and login.

Components

src/components/UserComponent.ts
import { BaseComponent, CompData, Component } from "bunsane/core/components";

@Component
export class UserTag extends BaseComponent {}

@Component
export class PasswordComponent extends BaseComponent {
@CompData()
value: string = "";
}

@Component
export class EmailComponent extends BaseComponent {
@CompData({ indexed: true })
value: string = "";

@CompData()
verified: boolean = false;
}

@Component
export class NameComponent extends BaseComponent {
@CompData()
value: string = "";
}

Auth Service

src/services/AuthService.ts
import { BaseService, Post } from "bunsane/service";
import { ApiDocs, ApiTags } from "bunsane/swagger";
import App from "bunsane/core/App";
import { Entity } from "bunsane/core/Entity";
import { Query } from "bunsane/query";
import { z } from "zod";
import {
UserTag, PasswordComponent, EmailComponent, NameComponent,
} from "../components/UserComponent";

const RegisterSchema = z.object({
email: z.string(),
password: z.string(),
name: z.string(),
});

const LoginSchema = z.object({
email: z.string(),
password: z.string(),
});

@ApiTags("Authentication")
class AuthService extends BaseService {
constructor(private app: App) {
super();
}

@Post("/v1/auth/register")
@ApiDocs({
summary: "Register a new user",
requestBody: {
required: true,
content: {
"application/json": {
schema: z.toJSONSchema(RegisterSchema),
},
},
},
responses: {
"201": { description: "User registered successfully" },
"400": { description: "Validation error or email already exists" },
},
})
async register(req: Request) {
const body = await req.json();
const parse = RegisterSchema.safeParse(body);

if (!parse.success) {
return Response.json(
{ errors: parse.error.issues.map((i) => i.message) },
{ status: 400 }
);
}

const { email, password, name } = parse.data;

// Check if email already exists
const existing = await new Query()
.with(EmailComponent, Query.filters(
Query.filter("value", Query.filterOp.EQ, email)
))
.exec();

if (existing.length > 0) {
return Response.json(
{ errors: ["Email already registered"] },
{ status: 400 }
);
}

const hashedPassword = await Bun.password.hash(password);

const user = Entity.Create()
.add(UserTag, {})
.add(EmailComponent, { value: email, verified: false })
.add(PasswordComponent, { value: hashedPassword })
.add(NameComponent, { value: name });

await user.save();

return Response.json(
{ message: "User registered", data: { id: user.id } },
{ status: 201 }
);
}

@Post("/v1/auth/login")
@ApiDocs({
summary: "Login user",
requestBody: {
required: true,
content: {
"application/json": {
schema: z.toJSONSchema(LoginSchema),
},
},
},
responses: {
"200": { description: "Login successful" },
"401": { description: "Invalid credentials" },
},
})
async login(req: Request) {
const body = await req.json();
const parse = LoginSchema.safeParse(body);

if (!parse.success) {
return Response.json(
{ errors: parse.error.issues.map((i) => i.message) },
{ status: 400 }
);
}

const { email, password } = parse.data;

const users = await new Query()
.with(EmailComponent, Query.filters(
Query.filter("value", Query.filterOp.EQ, email)
))
.exec();

if (users.length === 0) {
return Response.json(
{ errors: ["Invalid credentials"] },
{ status: 401 }
);
}

const user = users[0];
const passwordComp = await user.get(PasswordComponent);

const isValid = await Bun.password.verify(password, passwordComp?.value || "");
if (!isValid) {
return Response.json(
{ errors: ["Invalid credentials"] },
{ status: 401 }
);
}

// Generate JWT token (implement your JWT signing logic)
const token = generateJWTToken({ user_id: user.id });

return Response.json({
message: "Login successful",
data: {
access_token: token,
expires_at: Date.now() + 7 * 24 * 60 * 60 * 1000,
},
});
}
}

export default AuthService;

Entity Hooks

An example showing how to use entity hooks for side effects like logging and real-time notifications.

src/services/OrderService.ts
import { BaseService } from "bunsane/service";
import { GraphQLOperation } from "bunsane/gql";
import { ComponentTargetHook } from "bunsane/core/decorators/EntityHooks";
import type { EntityCreatedEvent, EntityUpdatedEvent } from "bunsane/core/events/EntityLifecycleEvents";
import App from "bunsane/core/App";
import { Entity } from "bunsane/core/Entity";
import { z } from "zod";
import { logger as MainLogger } from "bunsane/core/Logger";

const logger = MainLogger.child({ service: "OrderService" });

class OrderService extends BaseService {
constructor(private app: App) {
super();
OrderArcheType.registerFieldResolvers(this);
}

@ComponentTargetHook("entity.created", {
includeComponents: [OrderTag, OrderInfoComponent],
})
async onOrderCreated(event: EntityCreatedEvent) {
const orderEntity = event.entity;
const infoComp = await orderEntity.get(OrderInfoComponent);

logger.info({
msg: "New order created",
orderId: orderEntity.id,
customerId: infoComp?.customerId,
});
}

@ComponentTargetHook("entity.updated", {
includeComponents: [OrderTag, OrderStatusComponent],
})
async onOrderStatusChanged(event: EntityUpdatedEvent) {
const orderEntity = event.entity;
const statusComp = await orderEntity.get(OrderStatusComponent);

logger.info({
msg: "Order status updated",
orderId: orderEntity.id,
newStatus: statusComp?.value,
});

this.app.pubSub.publish(`order.${orderEntity.id}.status`, {
orderId: orderEntity.id,
status: statusComp?.value,
});
}

@GraphQLOperation({
type: "Mutation",
input: z.object({
customerId: z.string(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number(),
})),
}),
output: OrderArcheType,
})
async createOrder(args: any, context: GraphQLContext) {
const order = Entity.Create()
.add(OrderTag, {})
.add(OrderInfoComponent, {
customerId: args.customerId,
items: args.items,
createdAt: new Date(),
})
.add(OrderStatusComponent, { value: "pending" });

await order.save();
// The onOrderCreated hook fires automatically
return order;
}
}

export default OrderService;

Transaction Example

An example showing database transactions for atomic operations like fund transfers.

src/services/PaymentService.ts
import { BaseService } from "bunsane/service";
import { GraphQLOperation } from "bunsane/gql";
import App from "bunsane/core/App";
import { Entity } from "bunsane/core/Entity";
import db from "bunsane/database";
import { z } from "zod";
import type { GraphQLContext } from "bunsane/types/graphql.types";

class PaymentService extends BaseService {
constructor(private app: App) {
super();
}

@GraphQLOperation({
type: "Mutation",
input: z.object({
fromAccountId: z.string(),
toAccountId: z.string(),
amount: z.number(),
}),
output: z.object({
success: z.boolean(),
transactionId: z.string(),
}),
})
async transferFunds(
args: { fromAccountId: string; toAccountId: string; amount: number },
context: GraphQLContext
) {
const { fromAccountId, toAccountId, amount } = args;

const result = await db.transaction(async (trx) => {
const fromAccount = await Entity.FindById(fromAccountId, trx);
if (!fromAccount) throw new Error("Source account not found");

const fromBalance = await fromAccount.get(BalanceComponent, { trx });
if (!fromBalance || fromBalance.amount < amount) {
throw new Error("Insufficient funds");
}

const toAccount = await Entity.FindById(toAccountId, trx);
if (!toAccount) throw new Error("Destination account not found");

const toBalance = await toAccount.get(BalanceComponent, { trx });

await fromAccount.set(
BalanceComponent,
{ amount: fromBalance.amount - amount },
{ trx }
);
await fromAccount.save(trx);

await toAccount.set(
BalanceComponent,
{ amount: (toBalance?.amount || 0) + amount },
{ trx }
);
await toAccount.save(trx);

const txRecord = Entity.Create()
.add(TransactionTag, {})
.add(TransactionInfoComponent, {
fromAccountId,
toAccountId,
amount,
timestamp: new Date(),
});
await txRecord.save(trx);

return txRecord.id;
});

return { success: true, transactionId: result };
}
}

export default PaymentService;

Authentication Decorator Pattern

A reusable @RequireJWT decorator for protecting GraphQL operations.

AuthDecorator Utility

src/utilities/AuthDecorator.ts
import { GraphQLError } from "graphql";
import type { GraphQLContext } from "bunsane/types/graphql.types";

export function RequireJWT() {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;

descriptor.value = async function (...args: any[]) {
const context: GraphQLContext = args[1];

if (!context.jwt?.payload?.user_id) {
throw new GraphQLError("Authentication required", {
extensions: { code: "UNAUTHENTICATED" },
});
}

return originalMethod.apply(this, args);
};

return descriptor;
};
}

export function RequireRole(...roles: string[]) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;

descriptor.value = async function (...args: any[]) {
const context: GraphQLContext = args[1];

if (!context.jwt?.payload?.user_id) {
throw new GraphQLError("Authentication required", {
extensions: { code: "UNAUTHENTICATED" },
});
}

const userRole = context.jwt.payload.role;
if (!roles.includes(userRole)) {
throw new GraphQLError("Insufficient permissions", {
extensions: { code: "FORBIDDEN" },
});
}

return originalMethod.apply(this, args);
};

return descriptor;
};
}

Using the Decorators

src/services/UserService.ts
import { BaseService } from "bunsane/service";
import { GraphQLOperation } from "bunsane/gql";
import { Entity } from "bunsane/core/Entity";
import { Query } from "bunsane/query";
import type { GraphQLContext } from "bunsane/types/graphql.types";
import { RequireJWT, RequireRole } from "../utilities/AuthDecorator";

class UserService extends BaseService {
constructor(private app: App) {
super();
UserArcheType.registerFieldResolvers(this);
}

@RequireJWT()
@GraphQLOperation({
type: "Query",
output: UserArcheType,
})
async profile(args: {}, context: GraphQLContext) {
const userId = context.jwt.payload.user_id;
return await Entity.FindById(userId);
}

@RequireRole("admin")
@GraphQLOperation({
type: "Query",
output: [UserArcheType],
})
async listAllUsers(args: {}, context: GraphQLContext) {
return await new Query().with(UserTag).exec();
}
}

export default UserService;

JWT Authentication Setup

How to configure JWT authentication for your BunSane app.

src/App.ts
import App from "bunsane/core/App";
import { ServiceRegistry } from "bunsane/service";
import { createInlineSigningKeyProvider, useJWT } from "@graphql-yoga/plugin-jwt";

const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";

export default class SecureAPI extends App {
constructor() {
super("SecureAPI", "1.0.0");

const jwtPlugin = useJWT({
signingKeyProviders: [createInlineSigningKeyProvider(JWT_SECRET)],
tokenLookupLocations: [
(params) => {
const auth = params.request.headers.get("authorization");
if (auth && auth.startsWith("Bearer ")) {
return { token: auth.slice(7) };
}
return undefined;
},
],
tokenVerification: {
issuer: "my-api",
audience: "my-app",
algorithms: ["HS256"],
},
extendContext: true,
reject: {
missingToken: false, // Allow unauthenticated requests
invalidToken: true, // Reject invalid tokens
},
});

this.addYogaPlugin(jwtPlugin);

ServiceRegistry.registerService(new AuthService(this));
ServiceRegistry.registerService(new UserService(this));
}
}

Then in your services, access the JWT payload through the context:

@GraphQLOperation({
type: "Query",
output: UserArcheType,
})
async profile(args: {}, context: GraphQLContext) {
if (!context.jwt?.payload?.user_id) {
throw new GraphQLError("Authentication required", {
extensions: { code: "UNAUTHENTICATED" },
});
}

const userId = context.jwt.payload.user_id;
return await Entity.FindById(userId);
}