@owujib/sabi
v0.0.8
Published
A Laravel-inspired TypeScript backend framework for Node.js
Maintainers
Readme
Sabi
A Laravel-inspired TypeScript backend framework for Node.js, built on Express — dependency injection, routing, controllers, validation, service providers, and a CLI generator suite.
npx @owujib/sabi new my-app
cd my-app
npm run devEcosystem
| Package | Description |
|---|---|
| @owujib/sabi | Core framework — HTTP, routing, DI container, modules, CLI |
| @owujib/sabi-orm | Active Record ORM (Postgres/MySQL/SQLite) with migrations and seeders |
| @owujib/sabi-auth | JWT authentication, password hashing, route guards |
CLI
npx @owujib/sabi new my-app # scaffold a new project
sabi make:module billing --resource # controller + service + dto + entity
sabi make:controller Invoice --module billing
sabi make:middleware RateLimit
sabi --helpSee the full command reference in Getting Started.
Documentation
- Getting Started
- Directory Structure
- Routing
- Controllers
- Request & Response
- Validation & DTOs
- Dependency Injection
- Service Providers
- Configuration
- Views & Templating
- Error Handling
- ORM
- HTTP Adapters
License
ISC
Getting Started
Requirements
- Node.js >= 18
- TypeScript >= 5.0
- npm or yarn
Installation
Option A — Scaffold a new project (recommended)
Use the Sabi CLI to generate a fully configured project in one command:
npx @owujib/sabi new my-appThis creates the entire project structure, installs all dependencies, and gives you a working server with example routes and a posts module out of the box.
cd my-app
npm run devYour server is now running at http://localhost:3000.
Option B — Manual setup
1. Create your project
mkdir my-app
cd my-app
npm init -y2. Install dependencies
npm install @owujib/sabi reflect-metadata express class-validator class-transformer
npm install -D typescript ts-node-dev @types/node @types/express3. Configure TypeScript
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true
}
}
experimentalDecoratorsandemitDecoratorMetadataare required. The framework uses TypeScript decorators andreflect-metadatato wire up dependency injection and automatic DTO injection in controllers.
4. Add scripts to package.json
{
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only --exit-child src/main.ts",
"build": "tsc",
"start": "node dist/main.js"
}
}5. Bootstrap the app
Create src/bootstrap/app.ts:
import { Container } from '@owujib/sabi';
import { AppServiceProvider } from '../app/Providers/AppServiceProvider';
const app = new Container();
const provider = new AppServiceProvider();
provider.register(app);
provider.boot();
export { app };Create src/app/Providers/AppServiceProvider.ts:
import {
ServiceProvider,
Container,
Router,
ExpressAdapter,
HTTP_ADAPTER,
ExceptionHandler,
FallBackFilter,
HttpExceptionFilter,
ValidationPipe,
} from '@owujib/sabi';
export class AppServiceProvider extends ServiceProvider {
register(app: Container): void {
app.singleton(HTTP_ADAPTER, () => new ExpressAdapter());
app.singleton(ExceptionHandler, () => {
const handler = new ExceptionHandler();
handler.register(new HttpExceptionFilter());
handler.register(new FallBackFilter());
return handler;
});
app.singleton(ValidationPipe, () => new ValidationPipe());
app.singleton(Router, () =>
new Router(app, app.make(ExceptionHandler), app.make(HTTP_ADAPTER)),
);
}
boot(): void {}
}6. Create the entry point
Create src/main.ts:
import 'reflect-metadata';
import { app } from './bootstrap/app';
import { Router, HttpAdapter, HTTP_ADAPTER } from '@owujib/sabi';
require('./routes/web');
const router = app.make<Router>(Router);
router.register();
const adapter = app.make<HttpAdapter>(HTTP_ADAPTER);
const port = Number(process.env.PORT ?? 3000);
adapter.listen(port, () => console.log(`Server running on http://localhost:${port}`));
const shutdown = () => adapter.close(() => process.exit(0));
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
import 'reflect-metadata'must be the first import inmain.ts. It enables the metadata reflection that powers dependency injection and DTO validation.
7. Create your first route
Create src/routes/web.ts:
import { Route, WebRouteContract, HttpContext } from '@owujib/sabi';
Route.group(WebRouteContract, () => {
Route.get('/', (ctx: HttpContext) => {
return ctx.response.json({ message: 'Hello from Sabi!' });
});
});8. Run the server
npm run devEnvironment Variables
Create a .env file at the root of your project:
APP_NAME=my-app
APP_ENV=development
APP_DEBUG=true
APP_URL=http://localhost:3000
PORT=3000What's next?
Directory Structure
A Sabi project follows a convention-based structure inspired by Laravel. Here is the recommended layout:
my-app/
├── src/
│ ├── app/
│ │ ├── Http/
│ │ │ └── controllers/ # Global/shared controllers
│ │ │ └── HomeController.ts
│ │ ├── Modules/ # Feature modules
│ │ │ └── posts/
│ │ │ ├── controller/
│ │ │ │ └── PostController.ts
│ │ │ └── dto/
│ │ │ ├── CreatePostDto.ts
│ │ │ ├── UpdatePostDto.ts
│ │ │ ├── PostParamsDto.ts
│ │ │ └── PostQueryDto.ts
│ │ └── Providers/ # Service providers
│ │ └── AppServiceProvider.ts
│ ├── bootstrap/
│ │ └── app.ts # DI container bootstrap
│ ├── config/
│ │ ├── app.ts # App config class
│ │ └── database.ts # Database config class
│ ├── routes/
│ │ ├── api.ts # JSON API routes
│ │ └── web.ts # Web/HTML routes
│ └── main.ts # Application entry point
├── .env
├── .env.example
├── .gitignore
├── package.json
└── tsconfig.jsonWhat each part does
src/main.ts
The entry point. Bootstraps the DI container, loads routes, starts the HTTP server, and registers shutdown handlers.
src/bootstrap/app.ts
Creates and exports the root Container instance. Registers and boots all service providers. Everything is wired up here.
src/app/Providers/
Service providers register bindings into the DI container. Think of them as the wiring layer between your classes and the framework.
src/app/Http/controllers/
Shared controllers that don't belong to a specific feature module. Typically used for general pages like home, health checks, etc.
src/app/Modules/
Feature modules group related code together. Each module has its own controller, DTOs, services, and any other files it needs.
src/app/Modules/posts/
├── controller/
│ └── PostController.ts # Handles HTTP requests
├── dto/
│ ├── CreatePostDto.ts # Validates POST body
│ ├── UpdatePostDto.ts # Validates PUT/PATCH body
│ ├── PostParamsDto.ts # Validates route params (:id)
│ └── PostQueryDto.ts # Validates query string (?page=1)
├── service/
│ └── PostService.ts # Business logic (optional)
└── PostsModule.ts # Module registration (optional)src/routes/
Route definitions. Split into web.ts for HTML responses and api.ts for JSON responses. Both are loaded in main.ts before calling router.register().
src/config/
Plain TypeScript classes that read from process.env. No magic — just typed access to environment variables.
Naming conventions
| Thing | Convention | Example |
|---|---|---|
| Controllers | PascalCase + Controller | PostController |
| DTOs | PascalCase + source + Dto | CreatePostDto, PostParamsDto |
| Service Providers | PascalCase + ServiceProvider | AppServiceProvider |
| Config classes | PascalCase + Config | AppConfig, DatabaseConfig |
| Route files | camelCase | api.ts, web.ts |
DTO naming rules
The framework uses the DTO class name to determine where to read data from:
| DTO name contains | Data source |
|---|---|
| Body | Request body |
| Params | Route parameters (/posts/:id) |
| Query | Query string (?page=1&limit=10) |
| Headers | Request headers |
| (anything else) | Request body (fallback) |
Examples: CreatePostDto → body, PostParamsDto → params, PostQueryDto → query string.
Routing
Routes are defined using the Route class. All routes must be loaded before calling router.register() in main.ts.
Basic routes
import { Route, HttpContext } from '@owujib/sabi';
Route.get('/hello', (ctx: HttpContext) => {
return ctx.response.json({ message: 'Hello!' });
});
Route.post('/hello', (ctx: HttpContext) => {
return ctx.response.json({ message: 'Created!' }, 201);
});Available methods: get, post, put, patch, delete.
Route parameters
Use :param syntax to capture URL segments:
Route.get('/posts/:id', (ctx: HttpContext) => {
const { id } = ctx.params;
return ctx.response.json({ id });
});Controller routes
Pass a tuple of [ControllerClass, 'methodName'] instead of a function:
import { Route } from '@owujib/sabi';
import { PostController } from '../app/Modules/posts/controller/PostController';
Route.get('/posts', [PostController, 'index']);
Route.post('/posts', [PostController, 'store']);
Route.get('/posts/:id', [PostController, 'show']);Route groups
Groups let you apply a shared prefix or contract (web vs api) to a set of routes.
With a contract only
import { Route, ApiRouteContract } from '@owujib/sabi';
Route.group(ApiRouteContract, () => {
Route.get('/users', [UserController, 'index']); // GET /users
Route.post('/users', [UserController, 'store']); // POST /users
});With a prefix
Route.group(ApiRouteContract, '/api', () => {
Route.get('/users', [UserController, 'index']); // GET /api/users
Route.post('/users', [UserController, 'store']); // POST /api/users
});With middleware (coming soon)
Route.group(ApiRouteContract, '/api', () => {
Route.get('/profile', [UserController, 'profile']);
}, [AuthMiddleware]);Route contracts
Contracts define how a group of routes behave — what headers are set and how responses are rendered.
WebRouteContract
For routes that return HTML (views). Sets Content-Type: text/html.
import { Route, WebRouteContract } from '@owujib/sabi';
Route.group(WebRouteContract, () => {
Route.get('/', [HomeController, 'index']);
Route.get('/about', [HomeController, 'about']);
});ApiRouteContract
For routes that return JSON. Sets Content-Type: application/json.
import { Route, ApiRouteContract } from '@owujib/sabi';
Route.group(ApiRouteContract, '/api', () => {
Route.get('/posts', [PostController, 'index']);
});Loading routes in main.ts
Routes are registered statically, so you just require the route files before calling router.register():
import 'reflect-metadata';
import { app } from './bootstrap/app';
import { Router, HttpAdapter, HTTP_ADAPTER } from '@owujib/sabi';
require('./routes/web'); // load web routes
require('./routes/api'); // load api routes
const router = app.make<Router>(Router);
router.register(); // register all collected routes with Express
const adapter = app.make<HttpAdapter>(HTTP_ADAPTER);
adapter.listen(3000, () => console.log('Server running on http://localhost:3000'));Full example
src/routes/api.ts
import { Route, ApiRouteContract } from '@owujib/sabi';
import { PostController } from '../app/Modules/posts/controller/PostController';
Route.group(ApiRouteContract, '/api', () => {
Route.get('/posts', [PostController, 'index']);
Route.post('/posts', [PostController, 'store']);
Route.get('/posts/:id', [PostController, 'show']);
Route.put('/posts/:id', [PostController, 'update']);
Route.delete('/posts/:id', [PostController, 'destroy']);
});src/routes/web.ts
import { Route, WebRouteContract } from '@owujib/sabi';
import { HomeController } from '../app/Http/controllers/HomeController';
Route.group(WebRouteContract, () => {
Route.get('/', [HomeController, 'index']);
Route.get('/about', [HomeController, 'about']);
});Controllers
Controllers handle incoming HTTP requests and return responses. Each public method on a controller corresponds to a route action.
Creating a controller
import { HttpContext, Controller, Action } from '@owujib/sabi';
@Controller()
export class PostController {
@Action()
async index(ctx: HttpContext) {
return ctx.response.json({ data: [] });
}
}@Controller()
Applied to the class. Tells the framework this is a controller and enables the DI container to resolve its constructor dependencies. It also triggers TypeScript to emit the metadata needed for dependency injection.
@Action()
Applied to each route method. Required for the Router to read the method's parameter types so it can automatically inject HttpContext and validated DTOs.
Both decorators are required for DI and automatic DTO injection to work.
The HttpContext parameter
Every action receives HttpContext as a parameter. It gives you access to the request, response, DI container, and more.
@Action()
async show(ctx: HttpContext) {
const id = ctx.params.id; // route param
const page = ctx.request.query()?.page; // query string
return ctx.response.json({ id });
}See request-and-response for full details.
Automatic DTO injection
When you type a method parameter with a DTO class, the framework automatically:
- Reads the data from the correct source (body, params, query, headers)
- Validates it using
class-validator - Injects the validated instance into your method
import { HttpContext, Controller, Action } from '@owujib/sabi';
import { CreatePostDto } from '../dto/CreatePostDto';
import { PostParamsDto } from '../dto/PostParamsDto';
import { PostQueryDto } from '../dto/PostQueryDto';
@Controller()
export class PostController {
@Action()
async index(ctx: HttpContext, query: PostQueryDto) {
// query.page and query.limit are validated and typed
const page = Number(query.page ?? '1');
const limit = Number(query.limit ?? '10');
return ctx.response.json({ data: [], meta: { page, limit } });
}
@Action()
async show(ctx: HttpContext, params: PostParamsDto) {
// params.id is validated from the :id route segment
return ctx.response.json({ data: { id: params.id } });
}
@Action()
async store(ctx: HttpContext, body: CreatePostDto) {
// body is validated against CreatePostDto rules
return ctx.response.json({ data: body }, 201);
}
@Action()
async update(ctx: HttpContext, body: UpdatePostDto, params: PostParamsDto) {
// multiple DTOs in one method — order matters for the response
return ctx.response.json({ data: { id: params.id, ...body } });
}
@Action()
async destroy(ctx: HttpContext, params: PostParamsDto) {
return ctx.response.json({ data: { deleted: params.id } });
}
}The framework figures out the data source from the DTO class name:
| Name contains | Source |
|---|---|
| Body | ctx.request.all() — request body |
| Params | ctx.request.params() — route parameters |
| Query | ctx.request.query() — query string |
| Headers | ctx.request.headers() — request headers |
| (anything else) | ctx.request.all() — body (fallback) |
If validation fails, the framework automatically returns a 422 Unprocessable Entity response with the validation errors.
Constructor injection
Controllers support constructor injection. Services or config classes bound in the DI container are resolved automatically.
import { HttpContext, Controller, Action } from '@owujib/sabi';
import { PostService } from '../services/PostService';
@Controller()
export class PostController {
constructor(private postService: PostService) {}
@Action()
async index(ctx: HttpContext) {
const posts = await this.postService.findAll();
return ctx.response.json({ data: posts });
}
}For constructor injection to work, PostService must be resolvable from the DI container (either bound with app.singleton() or instantiatable without arguments).
Returning responses
Always return the response call — do not call res.send() or res.json() directly.
// ✓ correct
@Action()
async index(ctx: HttpContext) {
return ctx.response.json({ data: [] });
}
// ✓ correct — async operations
@Action()
async show(ctx: HttpContext, params: PostParamsDto) {
const post = await this.db.find(params.id);
return ctx.response.json({ data: post });
}
// ✗ wrong — never do this
@Action()
async index(ctx: HttpContext) {
ctx.response.json({ data: [] }); // missing return
}Throwing HTTP errors
Throw HttpException to return an error response from anywhere in the call chain:
import { HttpContext, Controller, Action } from '@owujib/sabi';
import { HttpException } from '@owujib/sabi';
@Controller()
export class PostController {
@Action()
async show(ctx: HttpContext, params: PostParamsDto) {
const post = null; // pretend we queried the database
if (!post) {
throw new HttpException(404, 'Post not found');
}
return ctx.response.json({ data: post });
}
}Request and Response
Every controller action and route closure receives an HttpContext object. It contains everything you need to read the incoming request and send a response.
HttpContext
import { HttpContext } from '@owujib/sabi';
Route.get('/example', (ctx: HttpContext) => {
// ctx.request — read request data
// ctx.response — send a response
// ctx.params — route parameters
// ctx.meta — per-request metadata store (Map)
});Request
ctx.request.all()
Returns the full request body as a plain object.
const body = ctx.request.all();
// { title: 'Hello', content: 'World' }ctx.request.params()
Returns route parameters as a plain object.
// Route: /posts/:id
// URL: /posts/42
const params = ctx.request.params();
// { id: '42' }ctx.request.query()
Returns the parsed query string as a plain object.
// URL: /posts?page=2&limit=20
const query = ctx.request.query();
// { page: '2', limit: '20' }ctx.request.headers()
Returns all request headers as a plain object.
const headers = ctx.request.headers();
// { 'content-type': 'application/json', authorization: 'Bearer ...' }ctx.request.method
The HTTP method in uppercase.
ctx.request.method // 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'ctx.request.url
The request URL path.
ctx.request.url // '/posts/42'ctx.params
Shortcut to route parameters (same as ctx.request.params()).
Route.get('/posts/:id', (ctx: HttpContext) => {
const { id } = ctx.params;
return ctx.response.json({ id });
});Response
ctx.response.json(data, status?)
Sends a JSON response. Default status is 200.
ctx.response.json({ message: 'Hello' }); // 200
ctx.response.json({ created: true }, 201); // 201
ctx.response.json({ error: 'Not found' }, 404); // 404ctx.response.html(content, status?)
Sends an HTML response.
ctx.response.html('<h1>Hello World</h1>');
ctx.response.html(renderedHtml, 200);ctx.response.send(data, status?)
Low-level send. Lets the contract (web/api) determine the content type.
ctx.response.send({ data: [] });
ctx.response.send('plain text', 200);ctx.view(template, data?)
Renders a Handlebars template and sends the HTML response. Only available when a view engine is configured.
await ctx.view('posts/index', { posts: [] });
await ctx.view('posts/show', { post: { id: 1, title: 'Hello' } });See Views & Templating for setup instructions.
Common response patterns
Success with data
return ctx.response.json({ data: post });Created resource
return ctx.response.json({ data: newPost }, 201);Paginated list
return ctx.response.json({
data: posts,
meta: {
page: 1,
limit: 10,
total: 100,
},
});No content
return ctx.response.json(null, 204);Error (prefer throwing HttpException instead)
return ctx.response.json({ message: 'Not found' }, 404);Reading headers in a route
Route.get('/protected', (ctx: HttpContext) => {
const auth = ctx.request.headers()['authorization'];
if (!auth) {
throw new HttpException(401, 'Unauthorized');
}
return ctx.response.json({ ok: true });
});Validation and DTOs
Sabi uses class-validator and class-transformer for request validation. You define a DTO (Data Transfer Object) class with validation decorators, and the framework automatically validates and injects it into your controller method.
Creating a DTO
import { IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator';
export class CreatePostDto {
@IsString()
@IsNotEmpty({ message: 'Title is required' })
@MinLength(3, { message: 'Title must be at least 3 characters' })
@MaxLength(100, { message: 'Title must not exceed 100 characters' })
title!: string;
@IsString()
@IsNotEmpty({ message: 'Content is required' })
@MinLength(10, { message: 'Content must be at least 10 characters' })
content!: string;
}Naming conventions
The DTO class name tells the framework where to read the data from:
| Name contains | Source |
|---|---|
| Body (e.g. CreateBodyDto) | Request body |
| Params (e.g. PostParamsDto) | Route parameters (/posts/:id) |
| Query (e.g. PostQueryDto) | Query string (?page=1) |
| Headers (e.g. PostHeadersDto) | Request headers |
| (anything else, e.g. CreatePostDto) | Request body (fallback) |
Using DTOs in controllers
Add the DTO as a typed parameter in your action method. The @Action() decorator is required for the framework to detect the parameter types.
import { HttpContext, Controller, Action } from '@owujib/sabi';
import { CreatePostDto } from '../dto/CreatePostDto';
import { PostParamsDto } from '../dto/PostParamsDto';
import { PostQueryDto } from '../dto/PostQueryDto';
@Controller()
export class PostController {
// Validates query string: ?page=1&limit=10
@Action()
async index(ctx: HttpContext, query: PostQueryDto) {
return ctx.response.json({ page: query.page, limit: query.limit });
}
// Validates route param: /posts/:id
@Action()
async show(ctx: HttpContext, params: PostParamsDto) {
return ctx.response.json({ id: params.id });
}
// Validates request body
@Action()
async store(ctx: HttpContext, body: CreatePostDto) {
return ctx.response.json({ data: body }, 201);
}
// Multiple DTOs — body + params
@Action()
async update(ctx: HttpContext, body: UpdatePostDto, params: PostParamsDto) {
return ctx.response.json({ id: params.id, ...body });
}
}What happens on validation failure
When validation fails, the framework automatically returns:
{
"status": 422,
"message": "Validation failed",
"errors": [
{
"property": "title",
"constraints": {
"isNotEmpty": "Title is required",
"minLength": "Title must be at least 3 characters"
}
}
]
}You do not need to handle validation errors manually.
Common DTO examples
Body DTO with optional fields (update)
import { IsString, IsOptional, MinLength } from 'class-validator';
export class UpdatePostDto {
@IsString()
@IsOptional()
@MinLength(3)
title?: string;
@IsString()
@IsOptional()
@MinLength(10)
content?: string;
}Params DTO
import { IsString, IsNotEmpty } from 'class-validator';
export class PostParamsDto {
@IsString()
@IsNotEmpty()
id!: string;
}Query DTO with pagination
import { IsOptional, IsNumberString } from 'class-validator';
export class PostQueryDto {
@IsOptional()
@IsNumberString({}, { message: 'Page must be a number' })
page?: string;
@IsOptional()
@IsNumberString({}, { message: 'Limit must be a number' })
limit?: string;
}Headers DTO
import { IsString, IsNotEmpty } from 'class-validator';
export class AuthHeadersDto {
@IsString()
@IsNotEmpty({ message: 'Authorization header is required' })
authorization!: string;
}Common class-validator decorators
| Decorator | What it validates |
|---|---|
| @IsString() | Value is a string |
| @IsNumber() | Value is a number |
| @IsBoolean() | Value is a boolean |
| @IsEmail() | Valid email address |
| @IsNotEmpty() | Not empty (not '', null, undefined) |
| @IsOptional() | Skip other validators if value is absent |
| @MinLength(n) | String length >= n |
| @MaxLength(n) | String length <= n |
| @Min(n) | Number >= n |
| @Max(n) | Number <= n |
| @IsIn([...]) | Value is one of the given options |
| @IsNumberString() | String that contains a valid number |
| @IsUUID() | Valid UUID |
| @IsDateString() | Valid ISO 8601 date string |
Full list: class-validator documentation
Dependency Injection
Sabi has a built-in DI container that resolves class dependencies automatically. You bind classes and factories into the container, then ask it to resolve them anywhere in your app.
The Container
The root container is created in bootstrap/app.ts and exported as app.
import { Container } from '@owujib/sabi';
const app = new Container();
export { app };Binding a singleton
A singleton is created once and reused across the entire app lifetime.
app.singleton(PostService, () => new PostService());Use a symbol as the token when the binding is not a class:
const DB = Symbol('DB');
app.singleton(DB, () => createDatabaseConnection());Binding a factory (new instance per call)
app.bind(PostService, () => new PostService());Every call to app.make(PostService) creates a fresh instance.
Resolving a binding
const postService = app.make<PostService>(PostService);
const db = app.make<Database>(DB);Auto-resolution
If a class is not explicitly bound, the container tries to construct it by resolving its constructor dependencies recursively. This works when the class has @Controller() (or any class decorator) applied.
@Controller()
export class PostController {
constructor(private postService: PostService) {}
}When the Router calls app.make(PostController), the container:
- Reads
design:paramtypesfrom the class metadata →[PostService] - Resolves
PostService(auto-constructs if not bound) - Creates
new PostController(postService)
Request-scoped container
Each HTTP request gets its own child container that inherits from the root container. This lets you bind per-request values like the authenticated user.
// Inside a middleware or provider:
ctx.container.singleton('authUser', () => resolvedUser);
// In a controller:
const user = ctx.container.make('authUser');Using the container in a service provider
import { ServiceProvider, Container } from '@owujib/sabi';
import { PostService } from '../app/Modules/posts/services/PostService';
import { DatabaseService } from './DatabaseService';
export class PostServiceProvider extends ServiceProvider {
register(app: Container): void {
app.singleton(DatabaseService, () => new DatabaseService());
// PostService depends on DatabaseService — container resolves it
app.singleton(PostService, () =>
new PostService(app.make(DatabaseService))
);
}
boot(): void {
// runs after all providers have registered
}
}Injecting config
import { CONFIG, ConfigRepository } from '@owujib/sabi';
import { AppConfig } from '../../config/app';
// In your provider:
app.singleton(AppConfig, () => new AppConfig());
app.singleton(CONFIG, () => {
const config = new ConfigRepository();
config.set('app', new AppConfig());
return config;
});Then inject it in a controller or service:
@Controller()
export class HomeController {
constructor(private config: AppConfig) {}
@Action()
async index(ctx: HttpContext) {
return ctx.response.json({ app: this.config.name });
}
}Symbol tokens
Use symbols for non-class bindings to avoid naming collisions:
import { HTTP_ADAPTER } from '@owujib/sabi'; // built-in symbol
// HTTP_ADAPTER = Symbol('HTTP_ADAPTER')
app.singleton(HTTP_ADAPTER, () => new ExpressAdapter());
const adapter = app.make<HttpAdapter>(HTTP_ADAPTER);Built-in tokens exported from @owujib/sabi:
| Token | What it resolves |
|---|---|
| HTTP_ADAPTER | The HTTP adapter (Express) |
| CONFIG | The ConfigRepository instance |
| VIEW_ENGINE | The Handlebars view engine |
Dependency Injection
Sabi has a built-in DI container that resolves class dependencies automatically. You bind classes and factories into the container, then ask it to resolve them anywhere in your app.
The Container
The root container is created in bootstrap/app.ts and exported as app.
import { Container } from '@owujib/sabi';
const app = new Container();
export { app };Binding a singleton
A singleton is created once and reused across the entire app lifetime.
app.singleton(PostService, () => new PostService());Use a symbol as the token when the binding is not a class:
const DB = Symbol('DB');
app.singleton(DB, () => createDatabaseConnection());Binding a factory (new instance per call)
app.bind(PostService, () => new PostService());Every call to app.make(PostService) creates a fresh instance.
Resolving a binding
const postService = app.make<PostService>(PostService);
const db = app.make<Database>(DB);Auto-resolution
If a class is not explicitly bound, the container tries to construct it by resolving its constructor dependencies recursively. This works when the class has @Controller() (or any class decorator) applied.
@Controller()
export class PostController {
constructor(private postService: PostService) {}
}When the Router calls app.make(PostController), the container:
- Reads
design:paramtypesfrom the class metadata →[PostService] - Resolves
PostService(auto-constructs if not bound) - Creates
new PostController(postService)
Request-scoped container
Each HTTP request gets its own child container that inherits from the root container. This lets you bind per-request values like the authenticated user.
// Inside a middleware or provider:
ctx.container.singleton('authUser', () => resolvedUser);
// In a controller:
const user = ctx.container.make('authUser');Using the container in a service provider
import { ServiceProvider, Container } from '@owujib/sabi';
import { PostService } from '../app/Modules/posts/services/PostService';
import { DatabaseService } from './DatabaseService';
export class PostServiceProvider extends ServiceProvider {
register(app: Container): void {
app.singleton(DatabaseService, () => new DatabaseService());
// PostService depends on DatabaseService — container resolves it
app.singleton(PostService, () =>
new PostService(app.make(DatabaseService))
);
}
boot(): void {
// runs after all providers have registered
}
}Injecting config
import { CONFIG, ConfigRepository } from '@owujib/sabi';
import { AppConfig } from '../../config/app';
// In your provider:
app.singleton(AppConfig, () => new AppConfig());
app.singleton(CONFIG, () => {
const config = new ConfigRepository();
config.set('app', new AppConfig());
return config;
});Then inject it in a controller or service:
@Controller()
export class HomeController {
constructor(private config: AppConfig) {}
@Action()
async index(ctx: HttpContext) {
return ctx.response.json({ app: this.config.name });
}
}Symbol tokens
Use symbols for non-class bindings to avoid naming collisions:
import { HTTP_ADAPTER } from '@owujib/sabi'; // built-in symbol
// HTTP_ADAPTER = Symbol('HTTP_ADAPTER')
app.singleton(HTTP_ADAPTER, () => new ExpressAdapter());
const adapter = app.make<HttpAdapter>(HTTP_ADAPTER);Built-in tokens exported from @owujib/sabi:
| Token | What it resolves |
|---|---|
| HTTP_ADAPTER | The HTTP adapter (Express) |
| CONFIG | The ConfigRepository instance |
| VIEW_ENGINE | The Handlebars view engine |
Service Providers
Service providers are the central place to register bindings into the DI container. Every feature that needs to be available across your app goes through a provider.
Anatomy of a provider
import { ServiceProvider, Container } from '@owujib/sabi';
export class PostServiceProvider extends ServiceProvider {
/**
* Register bindings into the container.
* Called before boot() on all providers.
*/
register(app: Container): void {
app.singleton(PostService, () => new PostService());
}
/**
* Perform actions that depend on all providers being registered.
* Called after register() on all providers.
*/
boot(): void {
// e.g. load config, initialize connections
}
}register()— bind things into the container. Never useapp.make()here because other providers may not have registered yet.boot()— safe to callapp.make()here; all providers have runregister().
Registering a provider
Add it to your bootstrap/app.ts:
import { Container } from '@owujib/sabi';
import { AppServiceProvider } from '../app/Providers/AppServiceProvider';
import { PostServiceProvider } from '../app/Providers/PostServiceProvider';
const app = new Container();
const providers = [
new AppServiceProvider(),
new PostServiceProvider(),
];
for (const provider of providers) {
provider.register(app);
}
for (const provider of providers) {
provider.boot();
}
export { app };The core AppServiceProvider
This is the minimum required provider every Sabi app needs:
import {
ServiceProvider,
Container,
Router,
ExpressAdapter,
HTTP_ADAPTER,
ExceptionHandler,
FallBackFilter,
HttpExceptionFilter,
ValidationPipe,
} from '@owujib/sabi';
export class AppServiceProvider extends ServiceProvider {
register(app: Container): void {
// HTTP server
app.singleton(HTTP_ADAPTER, () => new ExpressAdapter());
// Error handling — order matters: most specific first
app.singleton(ExceptionHandler, () => {
const handler = new ExceptionHandler();
handler.register(new HttpExceptionFilter()); // handles HttpException
handler.register(new FallBackFilter()); // catches everything else
return handler;
});
// DTO validation pipe
app.singleton(ValidationPipe, () => new ValidationPipe());
// Router
app.singleton(Router, () =>
new Router(app, app.make(ExceptionHandler), app.make(HTTP_ADAPTER)),
);
}
boot(): void {}
}Creating a feature provider
Group related bindings into their own provider:
import { ServiceProvider, Container } from '@owujib/sabi';
import { DatabaseService } from '../services/DatabaseService';
import { PostService } from '../Modules/posts/services/PostService';
import { UserService } from '../Modules/user/services/UserService';
export class FeatureServiceProvider extends ServiceProvider {
register(app: Container): void {
app.singleton(DatabaseService, () => new DatabaseService({
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 5432),
}));
app.singleton(PostService, () =>
new PostService(app.make(DatabaseService))
);
app.singleton(UserService, () =>
new UserService(app.make(DatabaseService))
);
}
boot(): void {
// Connect to database once all providers are registered
const db = app.make(DatabaseService); // safe here
db.connect();
}
}Config provider
import { ServiceProvider, Container, CONFIG, ConfigRepository } from '@owujib/sabi';
import { AppConfig } from '../../config/app';
import { DatabaseConfig } from '../../config/database';
export class ConfigServiceProvider extends ServiceProvider {
register(app: Container): void {
const appConfig = new AppConfig();
const dbConfig = new DatabaseConfig();
app.singleton(AppConfig, () => appConfig);
app.singleton(DatabaseConfig, () => dbConfig);
app.singleton(CONFIG, () => {
const repo = new ConfigRepository();
repo.set('app', appConfig);
repo.set('database', dbConfig);
return repo;
});
}
boot(): void {}
}View provider
import { ServiceProvider, Container, VIEW_ENGINE } from '@owujib/sabi';
import { HandlebarsEngine } from '@owujib/sabi';
import path from 'path';
export class ViewServiceProvider extends ServiceProvider {
register(app: Container): void {
app.singleton(VIEW_ENGINE, () =>
new HandlebarsEngine({
viewsDir: path.join(process.cwd(), 'src/views'),
defaultLayout: 'main',
partialsDir: path.join(process.cwd(), 'src/views/partials'),
})
);
}
async boot(): Promise<void> {
const engine = app.make<HandlebarsEngine>(VIEW_ENGINE);
await engine.loadPartials();
}
}Configuration
Sabi uses plain TypeScript classes for configuration. Each config class reads from process.env and provides typed access to values throughout your app.
Creating a config class
// src/config/app.ts
export class AppConfig {
readonly name = process.env.APP_NAME ?? 'my-app';
readonly env = process.env.APP_ENV ?? 'development';
readonly debug = process.env.APP_DEBUG === 'true';
readonly url = process.env.APP_URL ?? 'http://localhost:3000';
readonly port = Number(process.env.PORT ?? 3000);
}// src/config/database.ts
export class DatabaseConfig {
readonly host = process.env.DB_HOST ?? 'localhost';
readonly port = Number(process.env.DB_PORT ?? 5432);
readonly name = process.env.DB_NAME ?? 'app';
readonly user = process.env.DB_USER ?? 'root';
readonly password = process.env.DB_PASSWORD ?? '';
}Registering config in a provider
import { ServiceProvider, Container, CONFIG, ConfigRepository } from '@owujib/sabi';
import { AppConfig } from '../../config/app';
import { DatabaseConfig } from '../../config/database';
export class ConfigServiceProvider extends ServiceProvider {
register(app: Container): void {
const appConfig = new AppConfig();
const dbConfig = new DatabaseConfig();
// Bind individual config classes for direct injection
app.singleton(AppConfig, () => appConfig);
app.singleton(DatabaseConfig, () => dbConfig);
// Bind the unified ConfigRepository for key-based access
app.singleton(CONFIG, () => {
const repo = new ConfigRepository();
repo.set('app', appConfig);
repo.set('database', dbConfig);
return repo;
});
}
boot(): void {}
}Usage patterns
Pattern A — Inject the config class directly
Best when a service or controller needs config from a single namespace.
@Controller()
export class HomeController {
constructor(private config: AppConfig) {}
@Action()
async index(ctx: HttpContext) {
return ctx.response.json({ app: this.config.name, env: this.config.env });
}
}export class MailService {
constructor(private config: AppConfig) {}
send() {
if (!this.config.debug) {
// send real email
}
}
}Pattern B — Inject the ConfigRepository
Best when you need config from multiple namespaces.
import { CONFIG, ConfigRepository } from '@owujib/sabi';
export class AppBootstrapper {
constructor(
@Inject(CONFIG) private config: ConfigRepository
) {}
start() {
const appName = this.config.get('app.name');
const dbHost = this.config.get('database.host');
const debug = this.config.get<boolean>('app.debug', false);
}
}Pattern C — Inline in a provider
Best for one-off setup during boot.
boot(): void {
const config = app.make<AppConfig>(AppConfig);
if (config.debug) {
console.log('[Debug mode enabled]');
}
}ConfigRepository API
const config = app.make<ConfigRepository>(CONFIG);
// Set a namespace
config.set('app', { name: 'my-app', debug: true });
// Get a value by dot-notation key
config.get('app.name'); // 'my-app'
config.get('app.debug'); // true
config.get('app.missing', 'N/A'); // 'N/A' (default value)
// Get with type
config.get<boolean>('app.debug', false);
config.get<string>('app.name', 'default');
// Get the entire store
config.all();
// { app: { name: 'my-app', debug: true }, database: { ... } }Environment file
Create a .env file at your project root:
APP_NAME=my-app
APP_ENV=development
APP_DEBUG=true
APP_URL=http://localhost:3000
PORT=3000
DB_HOST=localhost
DB_PORT=5432
DB_NAME=my_db
DB_USER=postgres
DB_PASSWORD=secretCommit a .env.example with the same keys but no real values:
APP_NAME=
APP_ENV=development
APP_DEBUG=false
APP_URL=http://localhost:3000
PORT=3000
DB_HOST=localhost
DB_PORT=5432
DB_NAME=
DB_USER=
DB_PASSWORD=Sabi does not load
.envfiles automatically. Use dotenv if you need automatic loading:npm install dotenvThen add to the top of
main.ts:import 'dotenv/config'; import 'reflect-metadata';
Views & Templating
Sabi supports server-side HTML rendering using Handlebars. Views are rendered through the ctx.view() method and support layouts and partials.
Setup
1. Install Handlebars
npm install handlebars2. Register the ViewServiceProvider
// src/app/Providers/ViewServiceProvider.ts
import { ServiceProvider, Container, VIEW_ENGINE, HandlebarsEngine } from '@owujib/sabi';
import path from 'path';
export class ViewServiceProvider extends ServiceProvider {
register(app: Container): void {
app.singleton(VIEW_ENGINE, () =>
new HandlebarsEngine({
viewsDir: path.join(process.cwd(), 'src/views'),
defaultLayout: 'main',
partialsDir: path.join(process.cwd(), 'src/views/partials'),
})
);
}
async boot(): Promise<void> {
const engine = app.make<HandlebarsEngine>(VIEW_ENGINE);
await engine.loadPartials();
}
}3. Add it to bootstrap
// src/bootstrap/app.ts
const providers = [
new ConfigServiceProvider(),
new ViewServiceProvider(), // ← add this
new AppServiceProvider(),
];Directory structure
src/views/
├── layouts/
│ ├── main.hbs # Default layout
│ └── auth.hbs # Alternative layout (e.g. for login pages)
├── partials/
│ └── nav.hbs # Shared nav component
└── home/
├── index.hbs
└── about.hbsCreating a layout
Layouts wrap your view content. Use {{{body}}} (triple braces — unescaped HTML) to render the view inside the layout.
src/views/layouts/main.hbs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
</head>
<body>
{{> nav}}
<main>
{{{body}}}
</main>
</body>
</html>Creating a partial
Partials are reusable template fragments. Reference them with {{> partialName}}.
src/views/partials/nav.hbs
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
{{#if auth}}
<a href="/profile">Profile</a>
<a href="/logout">Logout</a>
{{else}}
<a href="/login">Login</a>
{{/if}}
</nav>Creating a view
src/views/home/index.hbs
<h1>{{title}}</h1>
<p>{{message}}</p>
<ul>
{{#each posts}}
<li>{{this.title}}</li>
{{else}}
<li>No posts yet.</li>
{{/each}}
</ul>Rendering a view from a controller
Use ctx.view(template, data) where template is the path relative to viewsDir without the .hbs extension.
import { HttpContext, Controller, Action } from '@owujib/sabi';
@Controller()
export class HomeController {
@Action()
async index(ctx: HttpContext) {
return ctx.view('home/index', {
title: 'Welcome',
message: 'Hello from Sabi!',
posts: [],
});
}
@Action()
async about(ctx: HttpContext) {
return ctx.view('home/about', {
title: 'About',
});
}
}The view is automatically wrapped in the defaultLayout you configured (main in the setup above).
Using a different layout
To use a different layout for a specific view, reference it explicitly in the view file:
{{!-- Use auth layout instead of main --}}
{{layout "auth"}}
<h1>Login</h1>
<form method="POST" action="/login">
<input name="email" type="email" placeholder="Email">
<input name="password" type="password" placeholder="Password">
<button type="submit">Login</button>
</form>Handlebars helpers (built-in)
| Helper | Usage |
|---|---|
| {{#if condition}} | Conditional block |
| {{#unless condition}} | Inverse conditional |
| {{#each array}} | Loop over array — use {{this}} inside |
| {{#each object}} | Loop over object — use {{@key}} and {{this}} |
| {{> partialName}} | Include a partial |
| {{!-- comment --}} | Template comment (not output to HTML) |
Routes for views
Use WebRouteContract for routes that render views:
import { Route, WebRouteContract } from '@owujib/sabi';
import { HomeController } from '../app/Http/controllers/HomeController';
Route.group(WebRouteContract, () => {
Route.get('/', [HomeController, 'index']);
Route.get('/about', [HomeController, 'about']);
});Error Handling
Sabi has a layered error handling system. Errors thrown anywhere in a request lifecycle are caught by the ExceptionHandler, which passes them through a chain of registered filters until one handles it.
Throwing HTTP errors
Use HttpException to return a structured error response from a controller, service, or anywhere in the request chain:
import { HttpException } from '@owujib/sabi';
throw new HttpException(404, 'Post not found');
throw new HttpException(401, 'Unauthorized');
throw new HttpException(403, 'Forbidden');
throw new HttpException(422, 'Validation failed');
throw new HttpException(500, 'Something went wrong');The HttpExceptionFilter (registered in your AppServiceProvider) catches these and returns:
{
"status": 404,
"message": "Post not found"
}How the filter chain works
The ExceptionHandler holds a list of filters. When an error is thrown:
- Each filter's
catch()method is checked in registration order - The first filter that returns
truefromcatch()handles the error and stops the chain - If no filter matches, the
FallBackFilterat the end catches everything
app.singleton(ExceptionHandler, () => {
const handler = new ExceptionHandler();
handler.register(new HttpExceptionFilter()); // handles HttpException
handler.register(new FallBackFilter()); // catches everything else
return handler;
});Registration order matters — put specific filters before general ones.
Built-in filters
HttpExceptionFilter
Catches HttpException instances and returns a JSON response with the status code and message.
Response:
{ "status": 404, "message": "Post not found" }FallBackFilter
Catches any unhandled error. In debug mode it shows the error message and stack trace. In production it returns a generic 500 response.
Development response:
{
"status": 500,
"message": "TypeError: Cannot read properties of undefined",
"stack": "..."
}Production response:
{ "status": 500, "message": "Internal Server Error" }Creating a custom filter
Implement the ExceptionFilter interface:
import { ExceptionFilter, HttpContext } from '@owujib/sabi';
export class DatabaseExceptionFilter implements ExceptionFilter {
catch(error: any, ctx: HttpContext): boolean {
// Only handle database-related errors
if (error?.code !== 'DB_ERROR') return false;
ctx.response.json({
status: 503,
message: 'Database unavailable. Please try again later.',
}, 503);
return true; // handled — stop the chain
}
}Register it in your provider:
app.singleton(ExceptionHandler, () => {
const handler = new ExceptionHandler();
handler.register(new DatabaseExceptionFilter()); // ← specific first
handler.register(new HttpExceptionFilter());
handler.register(new FallBackFilter()); // ← general last
return handler;
});Common error patterns
Not found
@Action()
async show(ctx: HttpContext, params: PostParamsDto) {
const post = await PostService.find(params.id);
if (!post) throw new HttpException(404, 'Post not found');
return ctx.response.json({ data: post });
}Unauthorized
Route.get('/dashboard', (ctx: HttpContext) => {
if (!ctx.auth) throw new HttpException(401, 'You must be logged in');
return ctx.response.json({ user: ctx.auth });
});Forbidden
@Action()
async destroy(ctx: HttpContext, params: PostParamsDto) {
const post = await PostService.find(params.id);
if (post.userId !== ctx.auth?.id) throw new HttpException(403, 'Forbidden');
await PostService.delete(params.id);
return ctx.response.json({ deleted: true });
}Validation error (manual)
throw new HttpException(422, 'Email is already in use');Debug mode
Set APP_DEBUG=true in your .env to see full stack traces in error responses during development. Set it to false in production.
APP_DEBUG=true # development
APP_DEBUG=false # productionThe FallBackFilter reads process.env.APP_DEBUG to decide how much detail to expose.
12. ORM
Sabi ships a first-party ORM package — @owujib/sabi-orm — built on the Active Record pattern.
Every model is a plain TypeScript class. You query, create, and persist records through static and instance methods on the class itself. No query builders, no repositories, no extra boilerplate.
The ORM is a wrapper: nothing about the query engine underneath it is part of the public API. You configure a driver, write migrations against a Schema, and query through BaseModel — you never import a third-party database library directly, and the CLI you run is sabi-orm, not a vendor's own tool.
Installation
npm install @owujib/sabi-orm pgSupported drivers:
postgres,mysql,sqlite. Install the matching driver package (pg,mysql2, orbetter-sqlite3) alongside@owujib/sabi-orm— that's the only place a database-specific package name shows up.
Setup
1. Database config
// src/config/database.ts
export class DatabaseConfig {
readonly host = process.env.DB_HOST ?? 'localhost';
readonly port = Number(process.env.DB_PORT ?? 5432);
readonly name = process.env.DB_NAME ?? 'app';
readonly user = process.env.DB_USER ?? 'root';
readonly password = process.env.DB_PASSWORD ?? '';
}2. Service provider
Register the ORM in a service provider so it boots with the app:
// src/app/Providers/DatabaseServiceProvider.ts
import { ServiceProvider } from '@owujib/sabi';
import { DatabaseServiceProvider as OrmProvider } from '@owujib/sabi-orm';
import { DatabaseConfig } from '../../config/database';
export class DatabaseServiceProvider extends ServiceProvider {
private ormProvider!: OrmProvider;
register(): void {
this.app.bindFactory(DatabaseConfig, () => new DatabaseConfig(), true);
const config = new DatabaseConfig();
this.ormProvider = new OrmProvider(this.app, {
driver: 'postgres',
connection: {
host: config.host,
port: config.port,
database: config.name,
user: config.user,
password: config.password,
},
pool: { min: 2, max: 10 },
});
this.ormProvider.register();
}
boot(): void {
this.ormProvider.boot();
}
}3. Register the provider
// src/bootstrap/app.ts
import { DatabaseServiceProvider } from '../app/Providers/DatabaseServiceProvider';
app.register([
// ...other providers
DatabaseServiceProvider,
]);Defining a model
import { BaseModel } from '@owujib/sabi-orm';
export class User extends BaseModel {
static tableName = 'users';
id!: number;
name!: string;
email!: string;
password!: string;
created_at!: Date;
updated_at!: Date;
}tableName— the database table this model maps to.primaryKey— defaults to'id'. Override if your PK column is different.- All column properties are declared directly on the class.
Querying
Find by primary key
const user = await User.find(1); // User | null
const user = await User.findOrFail(1); // User (throws 404 error if missing)Find by a column value
const user = await User.findBy('email', '[email protected]'); // User | nullGet all rows
const users = await User.all(); // User[]Advanced queries
query() returns a QueryBuilder for anything not covered by the built-in methods — a Sabi type, not the underlying query engine's:
const users = await User.query()
.where('active', true)
.orderBy('created_at', 'desc')
.limit(10);
const total = await User.query().where('active', true).count();
const emails = await User.query().pluck('email');Available methods: where, whereIn, whereNot, orderBy, limit, offset, select, first, count, pluck. QueryBuilder is awaitable directly (resolves to the matching rows).
Creating records
// Static — insert and return the new instance
const user = await User.create({ name: 'Ada', email: '[email protected]', password: 'hashed' });
// Instance — build then save
const user = new User();
user.name = 'Ada';
user.email = '[email protected]';
await user.save(); // INSERTUpdating records
const user = await User.findOrFail(1);
user.email = '[email protected]';
await user.save(); // UPDATE SET email = '...' — only changed columns are sentDirty tracking — save() compares the current state against the snapshot taken at load time and only sends the columns that actually changed.
user.isDirty(); // true if anything changed
user.isDirty('email'); // true only if email changed
user.getDirty(); // { email: '[email protected]' }Deleting records
const user = await User.findOrFail(1);
await user.delete();Serialising
user.toObject(); // plain object, strips _ prefixed internal fields
user.toJSON(); // same — called automatically by JSON.stringifyRelations
hasMany (one-to-many)
The parent model owns many related records.
export class User extends BaseModel {
static tableName = 'users';
posts(): Promise<Post[]> {
return this.hasMany(Post, 'user_id', 'posts');
}
}
// Usage
const user = await User.findOrFail(1);
const posts = await user.posts();belongsTo (many-to-one)
The child model points back to one parent.
export class Post extends BaseModel {
static tableName = 'posts';
user_id!: number;
user(): Promise<User | null> {
return this.belongsTo(User, 'user_id', 'user');
}
}
// Usage
const post = await Post.findOrFail(42);
const author = await post.user();Eager loading
Lazy-loading relations inside a loop causes N+1 queries. Use with() to batch-load relations upfront — it fires exactly 2 queries per relation, regardless of result set size.
// 1 query for users + 1 query for all their posts
const users = await User.with('posts');
users[0].posts; // Post[] — already attached, no extra DB hitMultiple relations:
// 3 queries total: users + posts + profiles
const users = await User.with('posts', 'profile');Relation names (
'posts','profile') must match the third argument passed tohasMany/belongsToin the model definition.
Transactions
Wrap multiple operations in a transaction. If anything inside the callback throws, all writes are rolled back automatically. No trx parameter needs to be passed through your call chain — the active transaction is propagated automatically.
import { BaseModel } from '@owujib/sabi-orm';
await BaseModel.transaction(async () => {
const user = await User.create({ name: 'Ada', email: '[email protected]', password: 'hashed' });
await Post.create({ user_id: user.id, title: 'Hello world', content: '...' });
// if this throws, both inserts are rolled back
});Nested transactions become savepoints automatically.
Migrations & seeds
Sabi has its own CLI — sabi-orm — for migrations and seeding. You never invoke a vendor CLI directly.
1. Config file
The CLI reads a SabiOrmConfig from a config module (default: sabi-orm.config.ts/.js in the current directory, or pass --config <path>):
// src/sabi-orm.config.ts
import type { SabiOrmConfig } from '@owujib/sabi-orm';
import { DatabaseConfig } from './config/database';
const config = new DatabaseConfig();
const ormConfig: SabiOrmConfig = {
driver: 'postgres',
connection: {
host: config.host,
port: config.port,
database: config.name,
user: config.user,
password: config.password,
},
migrations: { directory: './src/database/migrations' },
seeds: { directory: './src/seeds' },
};
export default ormConfig;2. package.json scripts
{
"scripts": {
"migrate": "sabi-orm --config ./src/sabi-orm.config.ts migrate",
"migrate:rollback": "sabi-orm --config ./src/sabi-orm.config.ts migrate:rollback",
"migrate:make": "sabi-orm --config ./src/sabi-orm.config.ts migrate:make",
"seed": "sabi-orm --config ./src/sabi-orm.config.ts seed",
"seed:make": "sabi-orm --config ./src/sabi-orm.config.ts seed:make"
}
}npm run migrate:make -- create_users_table
npm run migrate
npm run migrate:rollback3. Writing a migration
Migration files export up/down functions that receive a Schema — never the underlying query engine:
// src/database/migrations/20240101_create_users_table.ts
import type { Schema } from '@owujib/sabi-orm';
export async function up(schema: Schema): Promise<void> {
await schema.createTable('users', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
table.string('email').notNullable().unique();
table.string('password').notNullable();
table.timestamps(true, true);
});
}
export async function down(schema: Schema): Promise<void> {
await schema.dropTable('users');
}4. Writing a seed
Seed files export a seed function that receives a Db handle for inserting/deleting rows:
// src/seeds/users_seed.ts
import type { Db } from '@owujib/sabi-orm';
import bcrypt from 'bcrypt';
export async function seed(db: Db): Promise<void> {
await db('users').del();
const password = await bcrypt.hash('password123', 10);
await db('users').insert([
{ name: 'John Doe', email: '[email protected]', password },
]);
}Run it with npm run seed.
Full example
// UserService.ts
import { BaseModel } from '@owujib/sabi-orm';
import { User } from '../entities/User';
import { Post } from '../../posts/entities/Post';
export class UserService {
findAll() { return User.all(); }
findById(id: number) { return User.find(id); }
findWithPosts() { return User.with('posts'); }
create(data: { name: string; email: string; password: string }) {
return User.create(data);
}
async update(id: number, data: Partial<{ name: string; email: string }>) {
const user = await User.find(id);
if (!user) return null;
Object.assign(user, data);
await user.save();
return user;
}
async delete(id: number) {
const user = await User.find(id);
if (!user) return false;
await user.delete();
return true;
}
// Atomic — rolls back both inserts if anything fails
registerWithWelcomePost(name: string, email: string, password: string) {
return BaseModel.transaction(async () => {
const user = await User.create({ name, email, password });
await Post.create({ user_id: user.id, title: 'My first post', content: `Welcome, ${name}!` });
return user;
});
}
}HTTP Adapters
Sabi doesn't talk to Express (or any other server library) directly — every request passes through an HttpAdapter. Routing, controllers, DTO validation, and the exception handler are all adapter-agnostic; they only ever see the framework's own Request/Response wrappers. ExpressAdapter (the default) and RawHttpAdapter (plain Node http) are just two implementations of that same interface. Swap in your own to run Sabi on Fastify, Bun, a serverless handler, or anything else that speaks HTTP.
1. The contract
// @owujib/sabi
export abstract class HttpAdapter {
abstract listen(port: number, cb?: () => void): void;
abstract close(cb?: () => void): void;
abstract registerRoute(method: string, path: string, handler: Function, middleware?: Function[]): void;
abstract use(middleware: Function): void;
}The Router calls registerRoute once per route at boot, with a handler it builds internally — you don't write route logic, you just need to call handler(req, res) at the right time with objects shaped the way Request/Response expect.
2. What req and res need to look like
Router wraps whatever you pass into handler(req, res) with new Request(req, {...}) and new Response(res). Those wrappers only touch a handful of properties — your adapter's job is to normalize its native request/response objects to match:
req (read by Request's constructor and the router):
req.method— string, e.g.'GET'req.url— string, e.g.'/posts/1?sort=asc'req.headers— plain object of header name → value (acookieheader is parsed automatically)req.body— already-parsed body (object). Your adapter must parse the body before callinghandler— Sabi does not parse it for you.req.query— parsed query-string objectreq.params— route params object (e.g.{ id: '1' }), matched from the path pattern
res (read/written by Response's methods — json, text, html, redirect, stream, setCookie, etc.):
res.statusCode— settable numberres.setHeader(name, value)res.getHeader(name)— used bysetCookieto append to an existingSet-Cookieheaderres.end(data?)- for
.stream():resmust be a writable stream (stream.pipe(res))
If your underlying framework's request/response objects already s
