@hidayetcanozcan/nucleus-core
v2.0.0
Published
Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs with Bun and Elysia. Features automatic CRUD generation, schema-based multi-tenancy, type-safe entity definitions, hybrid search, audit logging, and seamless frontend i
Maintainers
Readme
@nucleus-ts/core
Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs
Table of Contents
Overview
@nucleus-ts/core is a comprehensive TypeScript framework for building scalable, multi-tenant SaaS applications. It provides end-to-end type safety from database schema to frontend API calls.
What This Framework Does
- Database Layer: Define tables using Drizzle ORM column builders
- Entity System: Wrap tables with metadata (search config, access control)
- Auto-Generated API: Get REST endpoints for all CRUD operations automatically
- Frontend Integration: Type-safe React hooks for API calls
- Multi-Tenancy: Schema-per-tenant isolation with PostgreSQL
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND (React) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ useGenericApiActions() → actions.GET_PRODUCTS.start() │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND (Bun + Elysia) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ nucleus() plugin → Auto-generated CRUD routes │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ entityRegistry → All entity definitions │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ DATABASE (PostgreSQL) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ schema: main │ │ schema: t1 │ │ schema: t2 │ │
│ │ - products │ │ - products │ │ - products │ │
│ │ - users │ │ - users │ │ - users │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘Installation
# Install the core package
bun add @nucleus-ts/core
# Install peer dependencies
bun add drizzle-orm pg elysia
bun add -d drizzle-kit @types/pg typescriptCore Concepts
What is an Entity?
An Entity is a complete definition of a database table plus its metadata:
| Component | Description |
|-----------|-------------|
| tablename | The actual database table name (e.g., "products") |
| columns | Drizzle ORM column definitions |
| searchConfig | How this entity can be searched, filtered, sorted |
| available_app_ids | Which applications can access this entity |
| available_schemas | Which tenant schemas include this entity |
| type carriers | TypeScript types for Create, Read, Update, Delete operations |
What are Type Carriers?
Type Carriers are undefined values with explicit type annotations that carry TypeScript types through the entity system. They enable end-to-end type inference:
// Type carrier example
entity_type: undefined as Product | undefined,
entity_create_type: undefined as Create | undefined,Why Type Carriers?
- Types cannot be passed as runtime values in JavaScript
- These
undefinedvalues carry the type information at compile time - The frontend can infer correct types for API payloads and responses
Base Columns
Every entity automatically includes these columns via ...base:
| Column | Type | Description |
|--------|------|-------------|
| id | uuid | Primary key, auto-generated with gen_random_uuid() |
| is_active | boolean | Soft delete flag, defaults to true |
| created_at | timestamp | Creation timestamp, auto-set |
| updated_at | timestamp | Last update timestamp, auto-updated |
| version | integer | Optimistic locking counter, starts at 1 |
Backend Usage
Complete Entity Definition Workflow
Creating an entity involves 4 steps:
- Define columns
- Create entity with
defineEntity() - Define custom types
- Export with type carriers
Below is a fully annotated example:
1. Column Definitions
Columns are defined using Drizzle ORM column builders. Import them from the package:
import {
base, // Base columns (id, is_active, created_at, updated_at, version)
varchar, // Variable-length string: varchar("name", { length: 255 })
text, // Unlimited text: text("description")
boolean, // Boolean: boolean("is_active")
integer, // Integer: integer("count")
timestamp, // Timestamp: timestamp("created_at")
uuid, // UUID: uuid("user_id")
jsonb, // JSON binary: jsonb("metadata")
} from "@nucleus-ts/core";Column Definition Syntax:
const columns = {
// REQUIRED: Spread base columns first
...base,
// String columns
name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 100 }).unique().notNull(),
description: text("description"), // nullable by default
// Boolean columns
is_featured: boolean("is_featured").default(false),
is_published: boolean("is_published").default(true).notNull(),
// Numeric columns
price: varchar("price", { length: 50 }), // Use varchar for decimal precision
stock_count: integer("stock_count").default(0),
// Foreign key columns
category_id: uuid("category_id"), // References another table
owner_id: uuid("owner_id").notNull(), // Required foreign key
// JSON columns
metadata: jsonb("metadata"), // Stores arbitrary JSON
settings: jsonb("settings").default({}),
// Timestamps
published_at: timestamp("published_at"),
expires_at: timestamp("expires_at"),
};Column Modifiers:
| Modifier | Description | Example |
|----------|-------------|---------|
| .notNull() | Column cannot be null | name: varchar("name", { length: 255 }).notNull() |
| .default(value) | Set default value | is_active: boolean("is_active").default(true) |
| .unique() | Column must be unique | email: text("email").unique().notNull() |
| .primaryKey() | Mark as primary key | (already handled by base.id) |
2. Entity Definition
After defining columns, create the entity using defineEntity():
import { defineEntity, createHybridSearchConfigFromColumns } from "@nucleus-ts/core";
const _entity = defineEntity({
// REQUIRED: Database table name (lowercase, plural, snake_case)
tablename: "products",
// REQUIRED: The columns object defined above
columns,
// OPTIONAL: Columns to create database indexes on (improves query performance)
indexColumns: ["name", "category_id", "is_featured"],
// REQUIRED: Which application IDs can access this entity
// Use ["*"] for all apps, or specific IDs like ["app1", "app2"]
available_app_ids: ["*"],
// REQUIRED: Which database schemas include this entity
// Use ["*"] for all schemas, or specific schemas like ["main", "tenant_1"]
available_schemas: ["*"],
// OPTIONAL: Schemas to exclude (overrides available_schemas)
excluded_schemas: [],
// OPTIONAL: CRUD methods to disable for this entity
// Options: CREATE, READ, UPDATE, DELETE, TOGGLE, VERIFICATION
excluded_methods: [],
// OPTIONAL: Set to true if this entity accepts file uploads
is_formdata: false,
// REQUIRED: Search and filter configuration
searchConfig: createHybridSearchConfigFromColumns("T_Products", columns, {
fields: {
// Columns to exclude from search config
exclude: ["metadata"],
// Override default field behavior
overrides: {
name: { searchable: true }, // Full-text searchable
description: { searchable: true },
category_id: { filterable: true }, // Can filter by this
is_featured: { filterable: true },
price: { sortable: true }, // Can sort by this
},
},
// Default sorting
defaultOrderBy: "created_at",
defaultOrderDirection: "desc", // "asc" or "desc"
}),
});Search Config Field Options:
| Option | Type | Description |
|--------|------|-------------|
| searchable | boolean | Include in full-text search queries |
| filterable | boolean | Allow filtering by exact match, in, like, etc. |
| sortable | boolean | Allow sorting by this column |
| operators | string[] | Allowed filter operators: ["eq", "in", "like", "gte", "lte"] |
3. Type Definitions
Define TypeScript types for CRUD operations. These types are custom per entity:
import type {
DefaultFilter,
DefaultOmitted,
DefaultOrderBy,
OrderDirection,
Pagination,
Serialize,
} from "@nucleus-ts/core";
// Base entity type (inferred from columns)
type Product = typeof _entity.infer.select;
// JSON-serialized type (dates become strings, etc.)
type ProductJSON = Serialize<Product> & {
// Add relation types here if needed
category?: CategoryJSON;
images?: ImageJSON[];
};
// Create payload - omit auto-generated fields
type Create = Omit<Product, DefaultOmitted>;
// DefaultOmitted = "id" | "created_at" | "updated_at" | "version"
// Read/List payload - query parameters
type Read = {
page?: number;
limit?: number;
search?: string;
orderBy?: DefaultOrderBy | "name" | "price"; // Add custom sort fields
orderDirection?: OrderDirection;
filters?: DefaultFilter & {
// Add custom filter fields
name?: string;
category_id?: string;
is_featured?: boolean;
price_min?: string;
price_max?: string;
};
};
// Update payload - partial create + id
type Update = Partial<Create> & { id?: string };
// Delete payload
type Delete = { id: string };
// List response type
type ListReturn = {
data: ProductJSON[];
pagination: Pagination;
};Pagination Type:
type Pagination = {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};4. Entity Export with Type Carriers
Export the entity with type carriers attached:
export const ProductEntity = {
// Spread the base entity (includes table, infer, createTableForSchema, etc.)
..._entity,
// TYPE CARRIERS - These carry TypeScript types through the system
entity_type: undefined as Product | undefined,
entity_json_type: undefined as ProductJSON | undefined,
entity_create_type: undefined as Create | undefined,
entity_read_type: undefined as Read | undefined,
entity_update_type: undefined as Update | undefined,
entity_delete_type: undefined as Delete | undefined,
entity_list_return_type: undefined as ListReturn | undefined,
entity_store_type: undefined as ProductJSON | undefined,
};Complete File Example:
// entities/product.ts
import {
base,
boolean,
createHybridSearchConfigFromColumns,
defineEntity,
text,
uuid,
varchar,
} from "@nucleus-ts/core";
import type {
DefaultFilter,
DefaultOmitted,
DefaultOrderBy,
OrderDirection,
Pagination,
Serialize,
} from "@nucleus-ts/core";
// ============================================================================
// COLUMNS
// ============================================================================
const columns = {
...base,
name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 100 }).notNull(),
description: text("description"),
price: varchar("price", { length: 50 }),
category_id: uuid("category_id"),
is_featured: boolean("is_featured").default(false),
is_available: boolean("is_available").default(true),
};
// ============================================================================
// ENTITY
// ============================================================================
const _entity = defineEntity({
tablename: "products",
columns,
indexColumns: ["name", "slug", "category_id", "is_featured"],
available_app_ids: ["*"],
available_schemas: ["*"],
searchConfig: createHybridSearchConfigFromColumns("T_Products", columns, {
fields: {
overrides: {
name: { searchable: true },
slug: { searchable: true },
description: { searchable: true },
category_id: { filterable: true },
is_featured: { filterable: true },
is_available: { filterable: true },
price: { sortable: true },
},
},
defaultOrderBy: "created_at",
defaultOrderDirection: "desc",
}),
});
// ============================================================================
// TYPES
// ============================================================================
type Product = typeof _entity.infer.select;
type ProductJSON = Serialize<Product>;
type Create = Omit<Product, DefaultOmitted>;
type Read = {
page?: number;
limit?: number;
search?: string;
orderBy?: DefaultOrderBy | "name" | "price";
orderDirection?: OrderDirection;
filters?: DefaultFilter & {
name?: string;
slug?: string;
category_id?: string;
is_featured?: boolean;
is_available?: boolean;
};
};
type Update = Partial<Create> & { id?: string };
type Delete = { id: string };
type ListReturn = {
data: ProductJSON[];
pagination: Pagination;
};
// ============================================================================
// EXPORT
// ============================================================================
export const ProductEntity = {
..._entity,
entity_type: undefined as Product | undefined,
entity_json_type: undefined as ProductJSON | undefined,
entity_create_type: undefined as Create | undefined,
entity_read_type: undefined as Read | undefined,
entity_update_type: undefined as Update | undefined,
entity_delete_type: undefined as Delete | undefined,
entity_list_return_type: undefined as ListReturn | undefined,
entity_store_type: undefined as ProductJSON | undefined,
};5. Entity Registry
Combine all entities into a registry:
// config/index.ts
import { createNucleusRegistry } from "@nucleus-ts/core";
import { ProductEntity } from "./entities/product";
import { CategoryEntity } from "./entities/category";
// Core entities from package (authentication, authorization, etc.)
export const nucleusRegistry = createNucleusRegistry({
audit: { enabled: true },
authentication: { enabled: true },
authorization: { enabled: true },
verification: { enabled: true },
notification: { enabled: true },
verificationNotification: { enabled: true },
tenant: { enabled: true },
});
// Combined registry: core + custom entities
export const entityRegistry = {
...nucleusRegistry.entities,
T_Products: ProductEntity,
T_Categories: CategoryEntity,
} as const;
export type EntityName = keyof typeof entityRegistry;6. Server Setup
// src/server.ts
import { Elysia } from "elysia";
import { nucleus } from "@nucleus-ts/core";
import { pushSchema } from "drizzle-kit/api";
import { entityRegistry } from "../config";
const app = new Elysia()
.use(
nucleus({
registry: entityRegistry,
appId: process.env.NUCLEUS_APP_ID ?? "*",
pushSchema, // Required for auto-migrations
schemaName: "main",
verbose: true,
})
)
.listen(3000);Generated Endpoints:
For each entity (e.g., T_Products), these endpoints are created:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /v1/products | List with pagination, search, filters |
| GET | /v1/products/:id | Get single record by ID |
| POST | /v1/products | Create new record |
| PUT | /v1/products/:id | Update existing record |
| DELETE | /v1/products/:id | Soft delete (set is_active=false) |
| PATCH | /v1/products/:id/toggle | Toggle is_active status |
Frontend Usage
useGenericApiActions Hook
This hook provides type-safe API actions for all entities:
import { useGenericApiActions } from "./hooks/useGenericApiActions";
function MyComponent() {
const actions = useGenericApiActions();
// Available actions per entity:
// actions.GET_PRODUCTS - List products
// actions.CREATE_PRODUCT - Create a product
// actions.UPDATE_PRODUCT - Update a product
// actions.DELETE_PRODUCT - Delete a product
// actions.TOGGLE_PRODUCT - Toggle is_active
}Best Practices
Pattern 1: useEffectEvent for Data Fetching
ALWAYS use useEffectEvent for API calls in effects to avoid stale closures and infinite loops:
"use client";
import { useEffect, useEffectEvent } from "react";
import { useGenericApiActions } from "./hooks/useGenericApiActions";
export default function ProductList() {
const actions = useGenericApiActions();
// ✅ CORRECT: Wrap API call in useEffectEvent
const fetchProducts = useEffectEvent(() => {
actions.GET_PRODUCTS.start({
payload: {
page: 1,
limit: 20,
orderBy: "created_at",
orderDirection: "desc",
},
onAfterHandle: (data) => {
// data is fully typed as ListReturn
console.log("Fetched products:", data.data.length);
console.log("Total pages:", data.pagination.totalPages);
},
onErrorHandle: (error) => {
console.error("Failed to fetch:", error.message);
},
});
});
// ✅ CORRECT: Call in useEffect with empty deps
useEffect(() => {
fetchProducts();
}, []);
return (
<div>
{/* Check loading state */}
{actions.GET_PRODUCTS.state.isPending && <p>Loading...</p>}
{/* Access data */}
{actions.GET_PRODUCTS.state.data?.data.map((product) => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
))}
</div>
);
}Pattern 2: Event Handlers
For button clicks and form submissions, call actions directly:
function CreateProductForm() {
const actions = useGenericApiActions();
const [name, setName] = useState("");
const handleSubmit = () => {
actions.CREATE_PRODUCT.start({
payload: {
name,
description: "New product",
price: "99.99",
is_available: true,
},
onAfterHandle: (product) => {
// product is typed as ProductJSON
toast.success(`Created: ${product.name}`);
setName("");
},
onErrorHandle: (error) => {
toast.error(error.message);
},
});
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button disabled={actions.CREATE_PRODUCT.state.isPending}>
{actions.CREATE_PRODUCT.state.isPending ? "Creating..." : "Create"}
</button>
</form>
);
}What NOT To Do
// ❌ WRONG: Don't use try/catch
try {
await actions.GET_PRODUCTS.start(...);
} catch (e) {}
// ❌ WRONG: Don't await the result
const data = await actions.GET_PRODUCTS.start(...);
// ❌ WRONG: Don't use type assertions
onAfterHandle: (data) => {
const products = data as Product[]; // WRONG - data is already typed
};
// ❌ WRONG: Don't add start to dependency array (causes infinite loop!)
useEffect(() => {
actions.GET_PRODUCTS.start(...);
}, [actions.GET_PRODUCTS.start]); // INFINITE LOOP!
// ❌ WRONG: Don't call start directly in useEffect without useEffectEvent
useEffect(() => {
actions.GET_PRODUCTS.start(...); // May cause issues
}, []);CRUD Operations
Create
actions.CREATE_PRODUCT.start({
payload: {
name: "New Product",
description: "Product description",
price: "99.99",
category_id: "uuid-here",
is_featured: false,
is_available: true,
},
onAfterHandle: (product) => {
console.log("Created:", product.id);
},
});Read (List)
actions.GET_PRODUCTS.start({
payload: {
page: 1,
limit: 20,
search: "laptop", // Full-text search
orderBy: "name",
orderDirection: "asc",
filters: {
is_available: true,
is_featured: true,
category_id: "uuid-here",
},
},
onAfterHandle: (data) => {
// data.data: ProductJSON[]
// data.pagination: { page, limit, total, totalPages, hasNext, hasPrev }
},
});Update
actions.UPDATE_PRODUCT.start({
payload: {
id: "product-uuid",
name: "Updated Name",
price: "149.99",
},
onAfterHandle: (product) => {
console.log("Updated:", product.name);
},
});Delete
actions.DELETE_PRODUCT.start({
payload: { id: "product-uuid" },
onAfterHandle: () => {
console.log("Deleted successfully");
},
});Toggle Active Status
actions.TOGGLE_PRODUCT.start({
payload: { id: "product-uuid" },
onAfterHandle: (product) => {
console.log("New status:", product.is_active);
},
});State Properties
| Property | Type | Description |
|----------|------|-------------|
| state.isPending | boolean | Request is in progress |
| state.data | T \| null | Last successful response data |
| state.error | Error \| null | Last error |
API Reference
defineEntity Options
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| tablename | string | Yes | Database table name |
| columns | object | Yes | Drizzle column definitions |
| indexColumns | string[] | No | Columns to index |
| available_app_ids | string[] | Yes | App IDs that can access |
| available_schemas | string[] | Yes | Schema names to include |
| excluded_schemas | string[] | No | Schemas to exclude |
| excluded_methods | GenericMethods[] | No | CRUD methods to disable |
| is_formdata | boolean | No | Accept multipart/form-data |
| searchConfig | HybridSearchConfig | Yes | Search configuration |
nucleus Plugin Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| registry | EntityRegistry | - | Entity registry object |
| appId | string | "*" | Application ID |
| schemaName | string | "main" | Database schema name |
| pushSchema | function | - | drizzle-kit pushSchema function |
| verbose | boolean | true | Log SQL statements |
| allowDataLoss | boolean | false | Allow destructive migrations |
Multi-Tenancy
Schema-per-tenant isolation:
import { getTenantDB, bootstrapSchema } from "@nucleus-ts/core";
// Get database connection for specific tenant
const db = await getTenantDB("tenant_slug");
// Create new tenant schema
await bootstrapSchema({
schema_name: "tenant_new",
entities: entityRegistry,
app_id: process.env.NUCLEUS_APP_ID!,
pushSchema,
});License
This software is proprietary and confidential. See LICENSE for terms.
Built with ❤️ by Hidayet Can Özcan
