Building Your SDK
You've built your schema. Now you want to use it. This guide shows how to wrap noorm in a TypeScript SDK that provides type-safe database access across your workers, servers, and CLI tools.
What You'll Build
A dedicated database package in your monorepo:
packages/
└── db/
├── package.json
├── src/
│ ├── index.ts # Public API
│ ├── client.ts # Database client
│ ├── testing.ts # Test utilities
│ ├── types.ts # Database types
│ └── domains/ # Domain classes
│ ├── users.ts
│ └── posts.ts
└── tests/ # Integration testsThis structure gives you:
- Type safety - Kysely types mirror your database
- Testability - Integration tests use real databases
- Portability - Same SDK works in workers, servers, CLI
Step 1: Set Up the Monorepo
Create a pnpm workspace:
mkdir my-project && cd my-project
pnpm initCreate pnpm-workspace.yaml:
packages:
- 'packages/*'
- 'apps/*'Step 2: Create the Database Package
mkdir -p packages/db
cd packages/db
pnpm initUpdate packages/db/package.json:
{
"name": "@my-project/db",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./testing": "./dist/testing.js"
},
"scripts": {
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@noormdev/sdk": "*",
"kysely": "^0.28.0",
"pg": "^8.16.0",
"zod": "^3.24.0"
},
"devDependencies": {
"typescript": "^5.9.0",
"vitest": "^4.0.0"
}
}Step 3: Define Your Types
Create src/types.ts with types that match your schema:
import { z } from 'zod';
/**
* Database type definitions matching SQL schema.
*
* These types are used by Kysely for type-safe queries.
* Keep them in sync with your SQL files.
*/
export interface UsersTable {
id: number;
name: string;
email: string | null;
bio: string | null;
created_at: Date;
}
export interface PostsTable {
id: number;
user_id: number;
title: string;
body: string | null;
created_at: Date;
}
export interface RecentPostsView {
id: number;
user_id: number;
title: string;
body: string | null;
created_at: Date;
author_name: string;
}
/**
* Complete database schema for Kysely.
*/
export interface Database {
users: UsersTable;
posts: PostsTable;
recent_posts: RecentPostsView;
}
// Domain types (what comes OUT of the database)
export type User = UsersTable;
export type Post = PostsTable;
export type RecentPost = RecentPostsView;
// ─────────────────────────────────────────────────────────────
// Input Schemas (what goes INTO the database)
// ─────────────────────────────────────────────────────────────
/**
* Define inputs as Zod schemas, then infer types from them.
*
* Input types differ from table types—they don't include
* auto-generated fields like `id` or `created_at`.
*/
export const CreateUserSchema = z.object({
name: z.string().min(1).max(255),
email: z.string().email().optional(),
bio: z.string().max(1000).optional(),
});
export const CreatePostSchema = z.object({
user_id: z.number().int().positive(),
title: z.string().min(1).max(255),
body: z.string().max(10000).optional(),
});
// Infer input types from schemas—no duplication
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type CreatePostInput = z.infer<typeof CreatePostSchema>;Zod gives you runtime validation that TypeScript can't. Never trust user input—validate it before it touches your database. This protects against malformed data, provides clear error messages, and catches mistakes that even you might make.
Type Generation
Kysely provides kysely-codegen to automatically generate types from your database:
pnpm add -D kysely-codegen
npx kysely-codegen --dialect postgres --out-file src/types.tsThis introspects your database and generates accurate TypeScript types.
Step 4: Create Domain Classes
Create classes for each domain that hold a reference to the database context.
src/domains/users.ts:
import type { Context } from '@noormdev/sdk';
import type { Database, User, CreateUserInput } from '../types.js';
import { CreateUserSchema } from '../types.js';
export class Users {
#ctx: Context<Database>;
constructor(ctx: Context<Database>) {
this.#ctx = ctx;
}
async findById(id: number): Promise<User | null> {
const result = await this.#ctx.kysely
.selectFrom('users')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return result ?? null;
}
async findByEmail(email: string): Promise<User | null> {
const result = await this.#ctx.kysely
.selectFrom('users')
.selectAll()
.where('email', '=', email)
.executeTakeFirst();
return result ?? null;
}
async create(input: CreateUserInput): Promise<User> {
// Validate input before touching the database
const validated = CreateUserSchema.parse(input);
const result = await this.#ctx.kysely
.insertInto('users')
.values({
name: validated.name,
email: validated.email ?? null,
bio: validated.bio ?? null,
})
.returningAll()
.executeTakeFirstOrThrow();
return result;
}
async list(): Promise<User[]> {
return this.#ctx.kysely
.selectFrom('users')
.selectAll()
.orderBy('created_at', 'desc')
.execute();
}
}src/domains/posts.ts:
import type { Context } from '@noormdev/sdk';
import type { Database, Post, RecentPost, CreatePostInput } from '../types.js';
import { CreatePostSchema } from '../types.js';
export class Posts {
#ctx: Context<Database>;
constructor(ctx: Context<Database>) {
this.#ctx = ctx;
}
async create(input: CreatePostInput): Promise<Post> {
const validated = CreatePostSchema.parse(input);
const result = await this.#ctx.kysely
.insertInto('posts')
.values({
user_id: validated.user_id,
title: validated.title,
body: validated.body ?? null,
})
.returningAll()
.executeTakeFirstOrThrow();
return result;
}
async getRecent(): Promise<RecentPost[]> {
return this.#ctx.kysely
.selectFrom('recent_posts')
.selectAll()
.orderBy('created_at', 'desc')
.execute();
}
async getByUser(userId: number): Promise<Post[]> {
return this.#ctx.kysely
.selectFrom('posts')
.selectAll()
.where('user_id', '=', userId)
.orderBy('created_at', 'desc')
.execute();
}
}Step 5: Create the Database Client
The client assembles all domains and exposes them as properties.
src/client.ts:
import { createContext, type Context } from '@noormdev/sdk';
import type { Database } from './types.js';
import { Users } from './domains/users.js';
import { Posts } from './domains/posts.js';
export interface ClientOptions {
config?: string;
requireTest?: boolean;
projectRoot?: string;
}
export class Client {
#ctx: Context<Database>;
readonly users: Users;
readonly posts: Posts;
constructor(ctx: Context<Database>) {
this.#ctx = ctx;
this.users = new Users(ctx);
this.posts = new Posts(ctx);
}
async connect(): Promise<void> {
await this.#ctx.connect();
}
async disconnect(): Promise<void> {
await this.#ctx.disconnect();
}
/**
* Reset database (teardown + build).
*/
async reset(): Promise<void> {
await this.#ctx.reset();
}
/**
* Truncate all tables (keep schema).
*/
async truncate(): Promise<void> {
await this.#ctx.truncate();
}
}
/**
* Create a database client.
*
* @example
* const db = await createClient({ config: 'dev' });
* await db.connect();
* const user = await db.users.findById(1);
*/
export async function createClient(options: ClientOptions = {}): Promise<Client> {
const ctx = await createContext<Database>({
config: options.config,
requireTest: options.requireTest,
projectRoot: options.projectRoot,
});
return new Client(ctx);
}
/**
* Create a connected client.
*
* @example
* const db = await connect({ config: 'dev' });
* const user = await db.users.findById(1);
*/
export async function connect(options: ClientOptions = {}): Promise<Client> {
const client = await createClient(options);
await client.connect();
return client;
}Step 6: Create the Public API
src/index.ts:
// Client
export { Client, createClient, connect, type ClientOptions } from './client.js';
// Types
export type {
Database,
User,
Post,
RecentPost,
CreateUserInput,
CreatePostInput,
} from './types.js';Step 7: Add Testing Utilities
Create src/testing.ts for integration test helpers:
import { createClient, Client } from './client.js';
export interface TestClientOptions {
projectRoot?: string;
}
/**
* Create a test database client.
*
* Uses requireTest: true to prevent accidentally running against production.
*
* @example
* const db = await createTestClient();
* await db.connect();
* await db.reset();
*/
export async function createTestClient(
options: TestClientOptions = {},
): Promise<Client> {
return createClient({
config: 'test',
requireTest: true,
projectRoot: options.projectRoot,
});
}
/**
* Setup hook for test suites.
*
* Returns connected client with clean database.
*
* @example
* describe('Users', () => {
* let db: Client;
*
* beforeAll(async () => {
* db = await setupTestDb();
* });
*
* afterAll(async () => {
* await db.disconnect();
* });
* });
*/
export async function setupTestDb(
options: TestClientOptions = {},
): Promise<Client> {
const db = await createTestClient(options);
await db.connect();
await db.reset();
return db;
}Step 8: Write Integration Tests
By testing your SDK against a real database, you're testing your entire data layer—tables, views, stored procedures, constraints, and all. If someone changes a column type, renames a table, or breaks a view, your tests will catch it. This is how you detect schema drift before it reaches production.
Create tests/users.test.ts:
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb } from '../src/testing.js';
import type { Client } from '../src/client.js';
describe('User Queries', () => {
let db: Client;
beforeAll(async () => {
db = await setupTestDb();
});
afterAll(async () => {
await db.disconnect();
});
beforeEach(async () => {
await db.truncate();
});
describe('create', () => {
it('creates a user with required fields', async () => {
const user = await db.users.create({ name: 'Alice' });
expect(user.id).toBeDefined();
expect(user.name).toBe('Alice');
expect(user.email).toBeNull();
expect(user.created_at).toBeInstanceOf(Date);
});
it('creates a user with all fields', async () => {
const user = await db.users.create({
name: 'Bob',
email: 'bob@example.com',
bio: 'Hello world',
});
expect(user.name).toBe('Bob');
expect(user.email).toBe('bob@example.com');
expect(user.bio).toBe('Hello world');
});
});
describe('findByEmail', () => {
it('finds existing user', async () => {
await db.users.create({ name: 'Alice', email: 'alice@example.com' });
const found = await db.users.findByEmail('alice@example.com');
expect(found).not.toBeNull();
expect(found?.name).toBe('Alice');
});
it('returns null for non-existent email', async () => {
const found = await db.users.findByEmail('nobody@example.com');
expect(found).toBeNull();
});
});
describe('list', () => {
it('returns users in descending order by created_at', async () => {
await db.users.create({ name: 'First' });
await db.users.create({ name: 'Second' });
const list = await db.users.list();
expect(list).toHaveLength(2);
expect(list[0].name).toBe('Second');
expect(list[1].name).toBe('First');
});
});
});Step 9: Configure Test Database
The SDK uses noorm configs. You'll need a test config that points to your test database. Add one via noorm TUI wherever your noorm project lives:
noormPress c for config, a to add:
- Name:
test - Database:
my_project_test - Is Test Database: Yes
Using Your SDK
Now you can use your SDK anywhere in the monorepo.
In a Server
// apps/api/src/routes/users.ts
import { connect } from '@my-project/db';
const db = await connect({ config: 'prod' });
app.get('/users', async (req, res) => {
const list = await db.users.list();
res.json(list);
});
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(Number(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});In a Worker
// apps/worker/src/jobs/send-welcome-email.ts
import { connect } from '@my-project/db';
export async function sendWelcomeEmail(userId: number) {
const db = await connect();
const user = await db.users.findById(userId);
if (!user?.email) {
console.log('User has no email, skipping');
return;
}
await emailService.send({
to: user.email,
subject: 'Welcome!',
body: `Hi ${user.name}, welcome aboard!`,
});
await db.disconnect();
}In a CLI Tool
// apps/cli/src/commands/list-users.ts
import { connect } from '@my-project/db';
export async function listUsersCommand() {
const db = await connect({ config: process.env.DB_CONFIG });
const list = await db.users.list();
console.table(list.map(u => ({
id: u.id,
name: u.name,
email: u.email ?? '(none)',
})));
await db.disconnect();
}In CI/CD
# .github/workflows/test.yml
name: Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install
- run: pnpm --filter @my-project/db test
env:
NOORM_CONNECTION_DIALECT: postgres
NOORM_CONNECTION_HOST: localhost
NOORM_CONNECTION_DATABASE: test
NOORM_CONNECTION_USER: postgres
NOORM_CONNECTION_PASSWORD: postgresFinal Structure
my-project/
├── pnpm-workspace.yaml
├── packages/
│ └── db/
│ ├── package.json
│ ├── tsconfig.json
│ ├── src/
│ │ ├── index.ts
│ │ ├── client.ts
│ │ ├── testing.ts
│ │ ├── types.ts
│ │ └── domains/
│ │ ├── users.ts
│ │ └── posts.ts
│ └── tests/
│ └── users.test.ts
└── apps/
├── api/ # Uses @my-project/db
├── worker/ # Uses @my-project/db
└── cli/ # Uses @my-project/dbWhat's Next?
- SDK Reference - Full API documentation
- Templates - Dynamic SQL generation
