# jsango: full documentation for LLMs > Generated from the jsango repository (https://raw.githubusercontent.com/flutterbuddy1/jsango/main). Sections are separated by "---". --- # Working on this project (instructions for AI coding agents) This is a **jsango** backend: a batteries-included TypeScript framework with routing, validation, ORM + migrations, auth, an admin panel, jobs, events, cache, WebSockets, OpenAPI and AI agents. Import everything from `'jsango'`. ## Rules 1. **Use jsango for everything it covers.** Don't add Express, Fastify, Koa, Prisma, TypeORM, Sequelize, Mongoose, Knex, Passport, jsonwebtoken, bcrypt, zod/joi, BullMQ, node-cron or socket.io. The table below shows the jsango API to use instead. 2. **Only use APIs that exist.** If the `jsango` MCP server is connected (configured in `.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`), call `get_api` for exact signatures, `search_docs` for how-to and `run_check` before finishing. Otherwise use go-to-definition on the `jsango` import (`node_modules/jsango/dist` re-exports the `@jsango/*` packages) or read https://flutterbuddy1.github.io/jsango/llms-full.txt. Don't guess method names. 3. **Database changes go through models and migrations**: edit `src/models/*`, run `npx jsango makemigrations`, review the generated file, run `npx jsango migrate`. Never edit a migration that has already been applied. 4. **Verify before finishing**: `npx tsc --noEmit` must pass, and so must `npx jsango migrate:check` when models changed and the tests (if the project has any). 5. **If jsango is missing something or behaves differently from its docs**, don't patch `node_modules` or swap in another library. Write the smallest workaround in this project, tell the user, and offer a GitHub issue (see the end of this file). ## Which API to use | Need | jsango | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | HTTP server, routes | `createApp()`, `app.get/post/put/patch/delete(path, ...middleware, handler)`, `app.listen(port)` | | Request data | `ctx.params.id`, `ctx.query.page`, `ctx.body` (the body validated by `validate()`), `await ctx.request.json()` (raw body), `ctx.request.headers.get(...)` | | REST resource for a model | `app.crud('/posts', Post, { access: { write: auth.required() }, schema, scope, hooks })` (see below) | | Request validation | `validate({ body: schema({ ... }) })` with `string()`, `email()`, `number()`, `boolean()`, `object()` | | Errors | `throw notFound('...')`, `badRequest(...)`, `unauthorized(...)`, `forbidden(...)` | | CORS / headers / rate limits | `app.use(securityHeaders())`, `app.use(cors({ origin: [...] }))`, `rateLimit({ max, windowSeconds })` (global or per route) | | Models / ORM | `defineModel(name, { ...fields }, { table, timestamps, softDelete, relations })`, `fields.*` | | Queries | `Model.find(id)`, `Model.where(...).first()`, `.query().where().orderBy().paginate({ page, pageSize })`, `create`, `update`, `delete` | | Migrations | `npx jsango makemigrations`, `npx jsango migrate`, `migrate:status`, `migrate:rollback` | | Database connection | `src/database.ts` (`DATABASE_URL` in `.env`: postgres, mysql, sqlite, mongodb) | | Auth (JWT, sessions, API keys, OAuth, 2FA) | `createAuth({ secret, users })`, `auth.login`, `auth.required({ roles, permissions })` | | Admin panel | `app.admin({ auth, resources: [Model], dashboard: [...], media: { local: new LocalDiskMediaStorage() } })` | | Background jobs / events / cache | `jobs.register` + `jobs.dispatch`, `events.on` + `events.emit`, `cache.remember` | | WebSockets | `app.ws('/chat/:room', auth.required(), { open, message, close })`, `socket.join/to/emit`, `app.to('user:').emit(...)` | | API docs | `app.openapi({ path: '/openapi.json' })` | | AI agents and tools | `agent({ ... })`, `tool({ ... })`, `app.agent(path, agent, { middleware: [auth.required()] })` | ## Project layout ``` src/index.ts createApplication(): routes, admin, openapi; starts the server src/database.ts DatabaseManager from .env (SQLite ./db.sqlite3 by default) src/models/*.ts one model per file (defineModel) migrations/ generated migrations: commit them jsango.config.ts CLI config (database, models, migrations paths) ``` ## Common code ```ts import { createApp, defineModel, fields, validate, schema, string, notFound } from 'jsango'; export const Post = defineModel( 'Post', { id: fields.id(), title: fields.string({ maxLength: 200 }), body: fields.text({ nullable: true }), published: fields.boolean({ defaultValue: false }), authorId: fields.integer(), }, { table: 'posts', timestamps: true } ); const app = createApp(); app.get('/posts', async ({ query }) => { const page = Number(query.page ?? 1); return Post.query().where('published', true).latest().paginate({ page, pageSize: 20 }); }); app.get('/posts/:id', async ({ params }) => { const post = await Post.find(params.id); if (!post) throw notFound('Post not found'); return post; }); app.post('/posts', validate({ body: schema({ title: string().min(3) }) }), async ({ body }) => { return Post.create({ ...body, authorId: 1 }); }); // The routes above are what app.crud('/posts', Post, { ... }) generates; see "REST resources" below. ``` ```ts import { createApp, createAuth, DatabaseAuthStore, notFound, forbidden } from 'jsango'; export const auth = createAuth({ secret: process.env.AUTH_SECRET!, // 32+ random characters users: { findById: (id) => User.find(id), findByLogin: (email) => User.where('email', email).first(), }, store: new DatabaseAuthStore({ connection: db }), }); const app = createApp(); app.post('/auth/login', async (ctx) => { const { email, password } = (await ctx.request.json()) as { email: string; password: string }; return auth.login(email, password, ctx); // { accessToken, refreshToken, expiresIn } }); app.get('/me', auth.required(), async (ctx) => auth.user(ctx)); app.delete('/posts/:id', auth.required(), async (ctx) => { const post = await Post.find(ctx.params.id); if (!post) throw notFound('Post not found'); // Identity ids are strings; integer primary keys need String() to compare. const me = auth.identity(ctx); if (String(post.authorId) !== me.id && !me.hasRole('admin')) throw forbidden('Not your post'); await post.delete(); return { ok: true }; }); app.admin({ auth, resources: [User, Post] }); // admin panel at /admin ``` ### REST resources: `app.crud` Use `app.crud` for any model that needs list / detail / create / update / delete endpoints instead of writing the five routes by hand. Reads are public and writes answer 403 until `access` allows them; the primary key, timestamps and sensitive fields are never written from the body nor returned. ```ts import { schema, string, events } from 'jsango'; app.crud('/posts', Post, { access: { read: 'public', write: auth.required() }, // or per route: list, detail, create, update, delete searchFields: ['title'], filterFields: ['published'], schema: schema({ title: string().min(3) }), // validates create, and update with changes applied scope: (q, ctx) => q.where('authorId', auth.identity(ctx).id), // users only see/change their rows hooks: { // run in one transaction with the write; throw badRequest()/forbidden() to cancel beforeCreate: (data, ctx) => ({ ...data, authorId: auth.identity(ctx).id }), afterCreate: (post) => events.emit('post.created', { id: post.get('id') }), }, }); ``` Write a custom route instead only when a write is a whole business process (placing an order: stock, coupons, tax, payment). Keep the reads on `app.crud` with `only: ['list', 'detail']` and add `app.post('/orders', auth.required(), validate(...), handler)` next to it. Auth notes: - Add `AUTH_SECRET=` (32+ random characters, e.g. `openssl rand -base64 48`) to `.env` and `.env.example`. - Store `passwordHash: await auth.hashPassword(password)` on the user model. Never return user records with `passwordHash` from your own routes. The results of `auth.login()`, `auth.refresh()` and `auth.issueTokens()` are safe to return: their `user` property is not serialized. - Roles come from the user's `role` (string) or `roles` (array) field. `isSuperuser: true` grants everything. - On `auth.optional()` routes, check `auth.identity(ctx).isAuthenticated`. `await auth.user(ctx)` is `null` for anonymous requests. - Admin panel access needs the `admin` or `staff` role (or `isSuperuser`). Fields whose names contain password, hash, secret, token or key (e.g. `passwordHash`) are hidden in the admin automatically. For a quick start, `app.admin({ auth: { email, password } })` uses one built-in account instead. Create the first admin with a small script (e.g. `scripts/create-admin.ts`, run with `npx tsx`): ```ts import 'dotenv/config'; import { configureDatabase } from '../src/database.js'; import { auth } from '../src/auth.js'; import { User } from '../src/models/user.js'; configureDatabase(); const [email, password] = process.argv.slice(2); await User.updateOrCreate( { email }, { role: 'admin', passwordHash: await auth.hashPassword(password!) } ); console.log(`Admin ${email} is ready.`); ``` ## Commands | Command | Purpose | | ------------------------------------------------------------------ | ------------------------------------- | | `npm run dev` | Start with reload | | `npx jsango makemigrations` | Create a migration from model changes | | `npx jsango migrate` (`--dry-run` shows SQL) | Apply migrations | | `npx jsango migrate:status` / `migrate:rollback` / `migrate:check` | Inspect, undo, CI check | | `npx jsango db:status` | Test the database connection | | `npx jsango make:admin ` | Generate an admin resource | | `npx jsango make:agent ` | Generate an AI agent | | `npx jsango routes` | List routes | | `npx jsango doctor` | Check the environment | ## Reporting a jsango bug or missing feature When the problem is in jsango (not in this project's code): if the jsango MCP server is connected, call its `report_issue` tool. It removes secrets, adds versions, searches for duplicates and returns a draft and a link. Then show the draft to the user. Without the MCP server: 1. Check the existing issues first: https://github.com/flutterbuddy1/jsango/issues?q=is%3Aissue+YOUR+KEYWORDS 2. Draft a short issue: jsango version (`npx jsango version`), Node.js version, database, a minimal reproduction, and the expected vs actual behaviour. **Remove secrets, `.env` values, private code and data.** 3. Show the draft to the user and ask before sending anything. With their OK, give them this link to review and submit (URL-encode the title and body): `https://github.com/flutterbuddy1/jsango/issues/new?title=&body=<body>&labels=ai-reported` Never create issues without the user's explicit approval. --- <!-- source: README.md (Overview & quick start) --> <p align="center"> <img src="images/logo.png" alt="JSango Logo" width="180" /> </p> <h1 align="center">JSango</h1> <p align="center"> <strong>Production-grade, batteries-included TypeScript backend framework.</strong><br> <em>"Powerful Internally, Simple Externally"</em> </p> <p align="center"> <a href="https://www.producthunt.com/products/jsango/reviews/new?utm_source=badge-product_review&utm_medium=badge&utm_source=badge-jsango" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/product_review.svg?product_id=1330206&theme=light" alt="JSango - The simplest way to build your next backend | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> </p> <p align="center"> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-1.6.0-green.svg" alt="Version: 1.6.0" /></a> <a href="tsconfig.base.json"><img src="https://img.shields.io/badge/TypeScript-Strict%205.8-blue.svg" alt="TypeScript: Strict" /></a> <a href="https://flutterbuddy1.github.io/jsango/"><img src="https://img.shields.io/badge/Docs-Landing%20Page-6366f1.svg" alt="Documentation Site" /></a> </p> --- ## Why JSango? JSango combines convention over configuration, developer ergonomics and built-in batteries with modern TypeScript type safety, a modular package architecture, and the high-throughput performance of contemporary JavaScript runtimes. - **One Single Dependency**: Install `jsango` and get HTTP routing, WebSockets, ORM, Validation, Auth, Admin UI, OpenAPI, Queue, and Events out of the box. - **Zero Boilerplate**: Write handlers that directly return objects, strings, streams, or promises. Automatic JSON serialization. - **Type-Safe Validation**: Define schemas once with fluent builders (`string()`, `number()`, `email()`). - **Intuitive ORM**: Expressive declarative models with static query helpers (`User.where('active', true).get()`, `User.find(id)`). - **Built-in WebSockets**: Broadcast to rooms and manage real-time sockets with simple, ergonomic APIs. - **Auto Admin & OpenAPI**: Instant OpenAPI documentation (`app.openapi()`) and metadata-driven Admin UI (`app.admin()`). - **Fast, measured**: 3.0–3.6× the throughput of Express and 82–96% of Fastify's on the same endpoints ([benchmarks/compare](benchmarks/compare), `pnpm bench:compare`). --- ## 5-Minute Quick Start ### 1. Scaffold a New Project ```bash npx jsango new my-app cd my-app npm install npm run makemigrations # create the first migration from src/models npm run migrate # apply it (SQLite by default, no database server needed) npm run dev # http://127.0.0.1:3000 ``` ### 2. Hello World in 6 Lines ```typescript import { createApp } from 'jsango'; const app = createApp(); app.get('/hello', () => ({ message: 'Hello from JSango!' })); await app.listen(3000); ``` --- ## Building with AI assistants Every `jsango new` project ships an `AGENTS.md` (read by Claude Code, Cursor, Copilot, Codex, ...) that makes AI agents build with jsango's APIs instead of other libraries and verify their work, plus MCP configs for the `jsango mcp` server: `get_api` (real signatures from your installed version), `search_docs`, `run_check` (type-check, migrations, tests, with fix hints) and `report_issue` (drafts a GitHub issue for a jsango bug with secrets removed and duplicates checked; you review and submit it yourself). No server to host: it runs locally from the npm package. Add all of it to an existing project with `npx jsango ai:init`. AI assistants can read the docs at [llms.txt](https://flutterbuddy1.github.io/jsango/llms.txt) / [llms-full.txt](https://flutterbuddy1.github.io/jsango/llms-full.txt). ## Essential Guides ### 1. Simple REST & CRUD APIs ```typescript import { createApp, model, fields, validate, schema, string, email, notFound } from 'jsango'; // Define ORM Model export const User = model('User', { id: fields.id(), name: fields.string(), email: fields.string({ unique: true }), active: fields.boolean({ defaultValue: true }), }); const app = createApp(); // Query all users app.get('/users', async () => { return User.all(); }); // Query single user app.get('/users/:id', async ({ params }) => { const user = await User.find(params.id); if (!user) throw notFound('User not found'); return user; }); // Create user with automatic schema validation app.post( '/users', validate({ body: schema({ name: string().min(2), email: email(), }), }), async ({ body }) => { return User.create(body); } ); // Or generate a REST resource in one line (reads public, writes locked: see below) app.crud('/api/users', User); await app.listen(3000); ``` #### CRUD resources with `app.crud` `app.crud(path, Model, options)` adds `GET /path` (paginated, `?search=`, filters), `GET /path/:id`, `POST /path`, `PUT|PATCH /path/:id` and `DELETE /path/:id`. It is safe by default: - **Reads are public, writes answer 403** until `access` allows them. - **Only real columns are written**: the primary key, timestamps, sensitive fields and privilege fields (`isAdmin`, `isSuperuser`, `isStaff`, `role(s)`, `permissions`, `emailVerified`) are ignored in the body, so no `{"isAdmin": true}` mass assignment. List fields yourself with `writable`. - **Sensitive fields are never returned**: names with password, secret, token, apiKey or hash; `hidden` adds more. ```typescript import { schema, string, number, badRequest } from 'jsango'; app.crud('/api/products', Product, { // 'public' or middleware, for every route or per `read` / `write` / list|detail|create|update|delete access: { read: 'public', write: auth.required({ roles: ['admin'] }) }, searchFields: ['name'], // ?search=phone filterFields: ['categoryId'], // ?categoryId=3 writable: ['name', 'price', 'stock', 'categoryId'], // default: every column but id/timestamps/sensitive schema: schema({ name: string().min(2), price: number().min(0) }), // 400 with field errors }); // Each user only sees and changes their own rows; business logic runs in hooks. app.crud('/api/addresses', Address, { access: auth.required(), scope: (query, ctx) => query.where('userId', auth.identity(ctx).id), hooks: { beforeCreate: (data, ctx) => ({ ...data, userId: auth.identity(ctx).id }), beforeDelete: async (address) => { if (await Order.where('addressId', address.get('id')).first()) throw badRequest('Address is in use'); }, }, }); ``` Hooks (`beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `beforeDelete`, `afterDelete`) run in one database transaction with the write: throwing in any of them rolls it back. `before*` hooks may return changed data. With a `scope`, a create or update that would put the record outside it (e.g. `ownerId` changed to someone else) is refused with 403 and rolled back. **When not to use `app.crud`:** a write that is a whole business process (placing an order: check stock, apply a coupon, add tax, charge, send email) belongs in its own route or service. Keep the reads from `app.crud` and leave out the write it replaces: ```typescript app.crud('/api/orders', Order, { access: auth.required(), only: ['list', 'detail'], scope: (query, ctx) => query.where('userId', auth.identity(ctx).id), }); app.post('/api/orders', auth.required(), validate({ body: PlaceOrder }), (ctx) => placeOrder(auth.identity(ctx), ctx.body) ); ``` #### CORS, security headers and rate limits ```typescript import { createApp, cors, securityHeaders, rateLimit } from 'jsango'; const app = createApp({ trustProxy: true }); // behind nginx / a load balancer: real client IPs app.use(securityHeaders()); // nosniff, no framing, referrer policy, HSTS on https app.use(cors({ origin: ['https://app.example.com'], credentials: true })); // browser apps elsewhere app.use(rateLimit({ max: 300, windowSeconds: 60 })); // per client IP // Stricter limit on one route (e.g. login); pass `store` to share counts between instances app.post('/login', rateLimit({ max: 10, windowSeconds: 60 }), loginHandler); ``` ### 2. Real-Time WebSockets & Rooms `app.ws` works like an HTTP route: same paths and params, same middleware (`auth.required()` runs on the connection request). ```typescript import { createApp, createAuth } from 'jsango'; const app = createApp(); const auth = createAuth({ secret: process.env.AUTH_SECRET!, users: userLookup }); app.ws('/chat/:room', auth.required(), { open(socket) { socket.join(socket.params.room); // socket.user is the signed-in user }, message(socket, data) { // every message; JSON is parsed for you }, close(socket) {}, }); // Event style: the client sends { "event": "typing", "data": { ... } } app.ws('/live', (socket) => { socket.on('typing', (data) => socket.to('lobby').emit('typing', data)); // everyone else in the room }); // Push from anywhere (HTTP routes, jobs, events). Signed-in sockets are in `user:<id>`. app.post('/orders', auth.required(), async (ctx) => { const order = await Order.create({ userId: auth.identity(ctx).id }); await app.to(`user:${auth.identity(ctx).id}`).emit('order.created', order); return order; }); await app.listen(3000); ``` | On the socket | Does | | --------------------------------------------- | ------------------------------------------------------------- | | `send(data)` / `emit(event, data)` | Send JSON / send `{ event, data }` | | `join(room)`, `leave(room)`, `rooms` | Rooms are shared by all routes | | `to(room).send/emit(...)` | Everyone in the room except this socket | | `broadcast(data)` | Everyone else on the same route | | `on('message' \| 'close' \| 'error' \| name)` | Listen; a custom name receives client `{ event: name, data }` | | `user`, `params`, `request`, `ctx`, `id` | Signed-in user, route params, the connection request | Secure by default: only pages from your own host may connect (`origins: ['https://app.com']` or `'*'` to change; apps without an `Origin` header are allowed), messages are limited to 64 KB (`maxPayload`), dead connections are dropped by a 30s ping, slow clients are disconnected instead of filling memory, and an error in a handler goes to `error` / the log instead of crashing the server. `server.close()` closes open sockets. ### 3. Background Jobs, Events & Cache ```typescript import { events, jobs, cache } from 'jsango'; // 1. Events events.on<{ id: string; email: string }>('user.registered', async (user) => { console.log('Welcome email queued for', user.email); }); await events.emit('user.registered', { id: '1', email: 'alex@example.com' }); // 2. Background Jobs jobs.register<{ to: string; subject: string }>('send-email', async ({ to, subject }) => { // perform async sending... }); await jobs.dispatch('send-email', { to: 'alex@example.com', subject: 'Welcome!' }); // 3. Cache with Stampede Protection const stats = await cache.remember('dashboard.stats', 60, async () => { return { activeUsers: 1420, uptime: process.uptime() }; }); ``` ### 4. Automatic Admin UI & OpenAPI Documentation ```typescript import { createApp, LocalDiskMediaStorage, S3MediaStorage } from 'jsango'; import { User } from './models/user.js'; const app = createApp(); // Mount OpenAPI 3.1 JSON and Swagger UI (High-contrast dark mode) app.openapi({ path: '/openapi.json', title: 'My Application API', version: '1.0.0', }); // Mount Instant Admin Dashboard with Model Introspection app.admin({ path: '/admin', resources: [User, Product, Category], // Optional: media library disks (default: ./uploads served at /media) media: { local: new LocalDiskMediaStorage(), s3: new S3MediaStorage({ bucket: 'assets', region: 'ap-south-1', accessKeyId: process.env.S3_KEY!, secretAccessKey: process.env.S3_SECRET!, }), }, }); await app.listen(3000); ``` In the admin, `belongsTo` relations become searchable dropdowns with **+ New** and **Edit** buttons that open the related record's form in a side panel. **Media** (sidebar) uploads, previews and deletes files on every configured disk (local, S3, Cloudflare R2, MinIO, DigitalOcean Spaces), and `image` / `file` fields upload there or pick from the library. ### 5. Database, Models & Migrations (PostgreSQL, MySQL, SQLite, MongoDB) Connect with one environment variable, describe tables as models, and let jsango write the migrations. Change a model, generate a migration, review it, apply it: ```bash # .env DATABASE_URL=postgres://app:secret@localhost:5432/myapp # or mysql://…, sqlite:./db.sqlite3, mongodb://… ``` ```typescript // src/models/post.ts import { defineModel, fields } from 'jsango'; export const Post = defineModel( 'Post', { id: fields.id(), title: fields.string({ maxLength: 200 }), userId: fields.integer(), publishedAt: fields.dateTime({ nullable: true }), }, { table: 'posts', timestamps: true, relations: { author: { type: 'belongsTo', target: 'User', foreignKey: 'userId' } }, } ); ``` ```bash npx jsango makemigrations # writes migrations/<timestamp>_create_posts.ts from your models npx jsango migrate # applies it (--dry-run prints the SQL) npx jsango db:status # checks the connection ``` `jsango new` sets all of this up (`jsango.config.ts`, `src/database.ts`, an example model, SQLite by default). The **[Database Guide](docs/database/README.md)** covers connecting, field types, queries, transactions, hand-written migrations, production deploys and troubleshooting. --- ### 6. Native AI Agents & Tool Calling Build production-grade AI agents, workflows, and tool execution with single-import simplicity: ```typescript import { createApp, agent, tool, object, string, number } from 'jsango'; // 1. Define type-safe tools with automatic JSON schema generation const lookupOrder = tool({ name: 'lookupOrder', description: 'Get order status by order ID', schema: object({ orderId: string() }), execute: async ({ orderId }) => { return await Order.find(orderId); }, }); const refundOrder = tool({ name: 'refundOrder', description: 'Issue refund for an order', schema: object({ orderId: string(), amount: number() }), requiresApproval: true, // Human-in-the-loop approval gate execute: async ({ orderId, amount }) => { return await PaymentService.refund(orderId, amount); }, }); // 2. Create the Agent const supportAgent = agent({ name: 'SupportAgent', instructions: 'Help customers check orders and handle refund requests.', tools: { lookupOrder, refundOrder }, }); // 3. Expose Agent over HTTP & SSE Streaming in 1 line (auth + rate limits via middleware) const app = createApp(); app.agent('/api/support', supportAgent, { middleware: [auth.required()] }); // 4. Or expose Agent over real-time WebSockets app.wsAgent('/ws/support', supportAgent, { middleware: [auth.required()] }); await app.listen(3000); ``` `POST /api/support` with `{ input, conversationId? }` answers `{ text, conversationId, toolCalls, usage, status }`; send the `conversationId` back to continue the conversation. The history and the system prompt are never returned, anonymous visitors get their own server-issued conversation, input is capped at 10,000 characters (`maxInputLength`), and a run stops when the client disconnects. Custom LLMs are one function (`provider(async ({ messages }) => text)`), and memory keeps token use low (old tool results aren't re-sent, history has a token budget, optional running summary). ### 7. Voice & Phone Calling Agents The same agent can talk, with any models: any LLM, any speech-to-text and any text-to-speech (including self-hosted models), or a speech-to-speech provider. Answers are spoken sentence by sentence while the LLM is still writing; interruptions abort the reply and memory keeps only what was heard. ```typescript import { agent, deepgram, cartesia, openaiRealtime, twilio, aiAdminPage } from 'jsango'; const support = agent({ provider: myLlm, instructions: 'You are a friendly support agent.' }); app.voiceAgent('/voice', support, { stt: deepgram(), tts: cartesia({ voiceId: 'your-voice-id' }) }); app.webrtcAgent('/voice/rtc', support, { realtime: openaiRealtime() }); // WebRTC, key stays on server const line = app.phoneAgent('/phone', support, { carrier: twilio(), stt: deepgram(), tts: cartesia({ voiceId: 'your-voice-id' }), }); await line.dial('+919876543210'); // outbound phone call // Usage, cost, latency, transcripts and recordings in the admin const insights = app.aiInsights({ store: db }); app.admin({ pages: [aiAdminPage(insights)] }); ``` Guides: [Voice](docs/ai/voice.md) · [Phone](docs/ai/phone.md) · [Insights](docs/ai/insights.md) · [Memory](docs/ai/memory.md) · runnable example: [examples/voice-agent](examples/voice-agent). --- ## CLI Commands | Command | Description | | -------------------------------------------- | --------------------------------------------------------------------------------------- | | `jsango new <name>` | Scaffold a new application (database, example model and migrations included) | | `jsango makemigrations` | Generate a migration from model changes (`migrate:generate`) | | `jsango migrate` | Apply pending migrations (`--dry-run` shows the SQL) | | `jsango migrate:status` / `migrate:rollback` | Show migration state / undo the last batch | | `jsango migrate:check` | Fail CI when a model change has no migration or migrations are pending | | `jsango db:status` | Test the configured database connections | | `jsango routes` | List all registered HTTP and WebSocket routes | | `jsango make:agent <name>` | Generate an AI Agent template | | `jsango ai:doctor` | Verify configured AI providers and API keys | | `jsango ai:init` | Add or refresh `AGENTS.md`, `CLAUDE.md` and MCP configs so AI coding agents use jsango | | `jsango mcp` | MCP server (stdio) for AI agents: `get_api`, `search_docs`, `run_check`, `report_issue` | | `jsango doctor` | Verify environment, dependencies, and configuration | --- ## Monorepo Packages For advanced customization, internal packages remain independently available: | Package | Responsibility | | :------------------------------------------ | :---------------------------------------------------------------------------------------- | | [`jsango`](packages/jsango) | **Unified high-level developer facade and app coordinator** | | [`@jsango/ai`](packages/ai) | **AI Agent runtime, providers, tools, workflows, RAG, MCP, voice agents and AI insights** | | [`@jsango/core`](packages/core) | Application lifecycle coordinator and structured error hierarchy | | [`@jsango/http`](packages/http) | HTTP request/response abstractions and streaming body parsers | | [`@jsango/router`](packages/router) | Segment Radix Trie router (>7.7M ops/sec) and parameter constraints | | [`@jsango/middleware`](packages/middleware) | Middleware execution pipeline and response normalizers | | [`@jsango/orm`](packages/orm) | Declarative models, AST query builder, and batch eager loading | | [`@jsango/database`](packages/database) | Multi-connection manager, connection pool, and transactions | | [`@jsango/validation`](packages/validation) | Fluent schema validation engine and request middleware | | [`@jsango/websocket`](packages/websocket) | Real-time WebSocket server, room manager, and heartbeat engine | | [`@jsango/admin`](packages/admin) | Metadata-driven administrative dashboard engine | | [`@jsango/auth`](packages/auth) | Authentication (JWT, Session, API Key) and policy authorization | | [`@jsango/cache`](packages/cache) | Driver-agnostic caching with stampede protection (`remember`) | | [`@jsango/queue`](packages/queue) | Asynchronous background job queues with exponential retries | | [`@jsango/events`](packages/events) | In-process and distributed asynchronous event bus | | [`@jsango/cli`](packages/cli) | CLI tooling and project generators | --- ## License MIT © JSango Authors. --- <!-- source: docs/database/README.md (Database guide) --> # Database Guide: Connections, Models & Migrations This guide covers everything you need to use a database with jsango: connecting to PostgreSQL / MySQL / SQLite / MongoDB, defining models, querying, and evolving your schema with migrations. The same models, queries and migration commands work on all of them. - [How the pieces fit together](#how-the-pieces-fit-together) - [Quick start](#quick-start) - [1. Connecting to a database](#1-connecting-to-a-database) - [2. `jsango.config.ts`](#2-jsangoconfigts) - [3. Defining models](#3-defining-models) - [4. Querying](#4-querying) - [4a. MongoDB](#4a-mongodb) - [5. Migrations](#5-migrations) - [6. Production checklist](#6-production-checklist) - [7. Troubleshooting](#7-troubleshooting) - [8. Limitations](#8-limitations) --- ## How the pieces fit together ``` .env ──► src/database.ts ──► DatabaseManager ◄── jsango.config.ts ◄── jsango CLI (DATABASE_URL) │ (database, models, (migrate, │ migrations paths) makemigrations) ▼ src/models/*.ts (defineModel) ──► the ORM runs SQL through the manager │ ▼ npx jsango makemigrations ──► migrations/<timestamp>_<name>.ts ──► npx jsango migrate ``` | Piece | Role | | ---------------------------- | ----------------------------------------------------------------------------------------- | | **`DatabaseManager`** | Owns connection pools. Created once from a config object, e.g. `databaseConfigFromEnv()`. | | **`setDatabaseManager(db)`** | Makes every model use that manager. Call it once at startup. | | **Models** (`defineModel`) | Describe tables in TypeScript. They are the **source of truth** for your schema. | | **Migration files** | Versioned, reviewable steps that change the real database to match the models. | | **`jsango.config.ts`** | Tells the CLI where the database, models and migrations are. | The workflow: **change a model → `makemigrations` → review the file → `migrate` → commit both.** --- ## Quick start ```bash npx jsango new my-app cd my-app npm install npm run makemigrations # creates migrations/<timestamp>_create_users.ts from src/models/user.ts npm run migrate # creates the tables npm run dev # http://127.0.0.1:3000/users ``` A new project uses SQLite (`./db.sqlite3`) by default, which needs no server and works on Node.js 22.13+. To switch databases, edit `.env` (see below). Your code stays the same. --- ## 1. Connecting to a database ### Supported databases | Database | `driver` | Install in your project | URL format | | ------------------------ | --------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------- | | PostgreSQL 12+ | `postgres` (aliases `postgresql`, `pg`) | `npm install pg` | `postgres://user:pass@host:5432/db` | | MySQL 8+ / MariaDB 10.5+ | `mysql` (alias `mariadb`) | `npm install mysql2` | `mysql://user:pass@host:3306/db` | | SQLite 3.35+ | `sqlite` | nothing on Node 22.13+ (`node:sqlite`); otherwise `npm install better-sqlite3` | `sqlite:./db.sqlite3` | | MongoDB 5+ | `mongodb` (alias `mongo`) | `npm install mongodb` | `mongodb://user:pass@host:27017/db` or `mongodb+srv://…` | | In-memory (tests) | `memory` | nothing | — | > The client packages are optional peer dependencies: install only the one you use. If it is > missing, the first query fails with a message telling you exactly what to install. ### Configure with environment variables (recommended) `databaseConfigFromEnv()` builds the configuration from `.env`: ```bash # .env — Option A: a single URL (wins if set) DATABASE_URL=postgres://app:secret@localhost:5432/myapp # .env — Option B: individual settings (used when DATABASE_URL is empty) DATABASE_DRIVER=mysql DATABASE_HOST=127.0.0.1 DATABASE_PORT=3306 DATABASE_NAME=myapp DATABASE_USER=app DATABASE_PASSWORD=secret DATABASE_SSL= # true / require, no-verify (self-signed), false DATABASE_POOL_MAX=10 DATABASE_QUERY_TIMEOUT=30000 # cancel queries after 30s (PostgreSQL / MySQL); also applies to migrations DATABASE_FILE=./db.sqlite3 # SQLite only ``` ```ts // src/database.ts (generated by `jsango new`) import { DatabaseManager, databaseConfigFromEnv, setDatabaseManager } from 'jsango'; export const db = new DatabaseManager(databaseConfigFromEnv()); export function configureDatabase() { setDatabaseManager(db); // all models now use this database return db; } ``` Passwords with special characters must be URL-encoded inside `DATABASE_URL` (`p@ss` → `p%40ss`), or use the separate `DATABASE_PASSWORD` variable instead. ### Configure in code ```ts import { DatabaseManager, setDatabaseManager } from 'jsango'; export const db = new DatabaseManager({ default: 'primary', connections: { primary: { driver: 'postgres', host: process.env.DB_HOST, port: 5432, database: 'myapp', username: 'app', password: process.env.DB_PASSWORD, ssl: true, // or { rejectUnauthorized: false } for self-signed certificates pool: { max: 20, connectionTimeoutMs: 10_000 }, }, // `driver` may be omitted when `url` is given; it is inferred from the scheme analytics: { url: process.env.ANALYTICS_URL }, local: { driver: 'sqlite', filename: './data/local.sqlite3' }, }, }); setDatabaseManager(db); ``` | Connection option | Meaning | | -------------------------------------------------- | ------------------------------------------------------------------------------------- | | `driver` | `postgres`, `mysql`, `sqlite`, `mongodb`, `memory` (optional if `url` is set) | | `url` | Full connection URL | | `host`, `port`, `database`, `username`, `password` | Individual settings | | `filename` | SQLite file path (`:memory:` for an in-memory database) | | `ssl` | `true`, `false`, or a TLS options object | | `pool` | `{ min, max, acquireTimeoutMs, idleTimeoutMs, connectionTimeoutMs, maxLifetimeMs }` | | `options` | Extra options passed to the client (`pg.Pool`, `mysql2.createPool`, `better-sqlite3`) | Models that don't set `connection` use the **default** connection. Whatever the default is called, the name `'default'` always resolves to it. ### Verify the connection at startup ```ts await db.verify(); // throws a descriptive error if it cannot connect await app.listen(3000); process.once('SIGTERM', async () => { await db.close(); // drain pools on shutdown process.exit(0); }); ``` From the terminal: ```bash npx jsango db:status ``` ``` Connection Driver Target Status Latency default postgres app@localhost:5432/myapp ✓ HEALTHY 4ms ``` Connection errors include the target (never the password) and a hint, for example: ``` Failed to connect to PostgreSQL at app@127.0.0.1:5432/myapp: connect ECONNREFUSED 127.0.0.1:5432 Hint: The server refused the connection. Is the database running and listening on this host/port? ``` ### Raw SQL Write SQL with `?` placeholders. They are converted to `$1, $2, …` for PostgreSQL automatically. (For MongoDB see [section 4a](#4a-mongodb).) ```ts const { rows } = await db.query('SELECT id, email FROM users WHERE created_at > ?', [since]); await db.transaction(async (tx) => { await tx.query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, from]); await tx.query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, to]); }); ``` --- ## 2. `jsango.config.ts` The CLI (`migrate`, `makemigrations`, `db:status`, …) reads this file from the project root. It loads `.env` first. ```ts import { defineConfig } from 'jsango'; import { db } from './src/database.js'; export default defineConfig({ database: db, // a DatabaseManager, or a config object like { default, connections } models: './src/models', // file(s) or folder(s) with defineModel() calls (scanned recursively) migrations: './migrations', // where migration files live }); ``` All keys are optional: | Key | Default | | ------------ | ------------------------------------------------------------- | | `database` | built from `DATABASE_URL` / `DATABASE_DRIVER` env vars | | `models` | `./src/models` | | `migrations` | `./migrations` | | `envFile` | `.env` (existing environment variables are never overwritten) | TypeScript files (the config, models and migrations) are loaded with `tsx`, so no build step is needed and the usual `./file.js` import style works. --- ## 3. Defining models ```ts // src/models/user.ts import { defineModel, fields } from 'jsango'; export const User = defineModel( 'User', { id: fields.id(), // auto-increment primary key email: fields.string({ maxLength: 255, unique: true }), name: fields.string({ maxLength: 120, nullable: true }), role: fields.string({ maxLength: 20, defaultValue: 'member', indexed: true }), isActive: fields.boolean({ defaultValue: true }), settings: fields.json({ nullable: true }), }, { table: 'users', // default: lower-cased model name + "s" timestamps: true, // adds createdAt / updatedAt, set automatically softDelete: false, // true adds deletedAt; delete() then only marks rows } ); ``` Fields are **NOT NULL by default**. Use `nullable: true` for optional columns. ### Field types | Field | PostgreSQL | MySQL | SQLite | JS value | | -------------------------------------- | -------------------------- | -------------------------------- | ----------------------------------- | ----------------------------------------------------- | | `fields.id()` | `SERIAL PRIMARY KEY` | `INT AUTO_INCREMENT PRIMARY KEY` | `INTEGER PRIMARY KEY AUTOINCREMENT` | `number` | | `fields.string({ maxLength })` | `VARCHAR(n)` (255) | `VARCHAR(n)` | `VARCHAR(n)` | `string` | | `fields.text()` | `TEXT` | `LONGTEXT` | `TEXT` | `string` | | `fields.integer()` | `INTEGER` | `INT` | `INTEGER` | `number` | | `fields.bigint()` | `BIGINT` | `BIGINT` | `BIGINT` | `bigint` (a string in JSON) | | `fields.float()` / `number()` | `DOUBLE PRECISION` | `DOUBLE` | `REAL` | `number` | | `fields.decimal({ precision, scale })` | `NUMERIC(p,s)` | `DECIMAL(p,s)` | `NUMERIC(p,s)` | `number` (`string` if a number can't hold it exactly) | | `fields.boolean()` | `BOOLEAN` | `TINYINT(1)` | `INTEGER` (0/1) | `boolean` | | `fields.dateTime()` | `TIMESTAMP WITH TIME ZONE` | `DATETIME(3)` (UTC) | `DATETIME` (ISO text) | `Date` | | `fields.date()` | `DATE` | `DATE` | `DATE` | `Date` at UTC midnight (`'2026-10-09'` in JSON) | | `fields.time()` | `TIME` | `TIME` | `TIME` | `string` (`'09:30:00'`) | | `fields.json()` | `JSONB` | `JSON` | `TEXT` | object / array | | `fields.uuid()` | `UUID` | `CHAR(36)` | `VARCHAR(36)` | `string` | | `fields.binary()` | `BYTEA` | `LONGBLOB` | `BLOB` | `Uint8Array` | | `fields.objectId()` | `VARCHAR(24)` | `VARCHAR(24)` | `VARCHAR(24)` | `string` (a native `ObjectId` on MongoDB) | Values are converted both ways: booleans come back as `true`/`false` on every database, dates as `Date`, JSON as parsed objects. Decimals that a JavaScript number can't represent exactly (e.g. `NUMERIC(20,8)` amounts) stay strings, so reading and saving a record never changes them. ### Field options | Option | Effect | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `nullable: true` | Allows `NULL` (default: NOT NULL) | | `unique: true` | Unique constraint `uq_<table>_<column>` | | `indexed: true` | Index `idx_<table>_<column>` | | `defaultValue: x` | Default for new rows; a function (`() => crypto.randomUUID()`) is evaluated by the ORM and isn't stored in the schema | | `maxLength: n` | `VARCHAR(n)` | | `precision`, `scale` | For `decimal` | | `columnName: 'x'` | Column name when it differs from the property name | | `primaryKey`, `autoIncrement` | For custom keys. `fields.uuid({ primaryKey: true })` generates a random UUID for every new row | Model options: `table`, `connection` (named connection), `primaryKey`, `timestamps` (`true` or `{ createdAt: 'created_at', updatedAt: 'updated_at' }`), `softDelete` (`true` or `{ deletedAt: 'deleted_at' }`), `indexes: [{ columns: ['a', 'b'], unique: true }]`, `relations`. ### Relations ```ts export const Post = defineModel( 'Post', { id: fields.id(), title: fields.string(), userId: fields.integer(), // the foreign key column }, { table: 'posts', timestamps: true, relations: { // posts.userId -> users.id; creates a FOREIGN KEY in the migration author: { type: 'belongsTo', target: 'User', foreignKey: 'userId' }, }, } ); // on User: // relations: { posts: { type: 'hasMany', target: 'Post', foreignKey: 'userId' } } ``` | Type | Meaning | `foreignKey` lives on | | -------------------- | ----------------------------------------------------------------- | --------------------- | | `belongsTo` | this row points to one parent | this model | | `hasOne` / `hasMany` | children point to this row | the target model | | `manyToMany` | via a pivot model: `through`, `pivotForeignKey`, `pivotTargetKey` | the pivot model | `target` can be the model name (`'User'`) or a function (`() => User`), which avoids circular imports. `belongsTo` foreign keys use `ON DELETE CASCADE`, or `SET NULL` when the field is nullable. Override it with `options: { onDelete: 'RESTRICT' }`. --- ## 4. Querying ```ts // Create const user = await User.create({ email: 'a@example.com', name: 'Ada' }); user.id; // assigned by the database user.createdAt; // Date await Post.bulkCreate([ { title: 'One', userId: user.id }, { title: 'Two', userId: user.id }, ]); // Read await User.find(1); // by primary key, or null await User.findOrFail(1); // throws ModelNotFoundError await User.where('email', 'a@example.com').first(); await User.where({ role: 'admin', isActive: true }).get(); await User.query() .where('createdAt', '>=', since) .whereIn('role', ['admin', 'editor']) .whereNotNull('name') .orWhere('email', 'LIKE', '%@example.com') .orderBy('createdAt', 'DESC') .limit(20) .offset(40) .get(); await User.count(); await User.where('isActive', true).exists(); const page = await User.query().orderBy('id').paginate({ page: 2, pageSize: 25 }); // page.items, page.total, page.page, page.pageSize, page.totalPages for await (const u of User.query().cursor(500)) { /* stream large tables in batches */ } // Update user.name = 'Ada Lovelace'; await user.save(); // only changed columns are written await User.where('isActive', false).update({ role: 'inactive' }); // Delete await user.delete(); await User.where('role', 'spam').delete(); ``` Operators: `=`, `!=`, `<>`, `>`, `>=`, `<`, `<=`, `LIKE`, `NOT LIKE`, `ILIKE` (runs as case-insensitive `LIKE` on MySQL/SQLite), `IN`, `NOT IN`. `where('x', null)` compiles to `IS NULL`. Any other operator is rejected, so user input can never inject SQL through the operator argument. Keys that aren't declared fields are ignored on insert/update. You can pass a request body to `create()` without it failing on unknown columns. You should still validate input. ### Advanced queries These work identically on PostgreSQL, MySQL, SQLite and MongoDB: ```ts // Grouped conditions: genre = 'fiction' AND (price < 10 OR pages > 500) await Book.where('genre', 'fiction') .where((q) => q.where('price', '<', 10).orWhere('pages', '>', 500)) .get(); await Book.whereNot('status', 'draft').get(); await Book.whereNot((q) => q.where('genre', 'kids').orWhere('price', 0)).get(); await Book.whereBetween('price', [10, 25]).get(); // also whereNotBetween / orWhereBetween await Book.whereLike('title', '%guide%').get(); // case-insensitive everywhere await Book.whereLike('code', 'AB_%', { caseSensitive: true }).get(); // exact case on PostgreSQL / MongoDB await Book.whereContains('title', req.query.search).get(); // user input: % and _ match literally await Book.where('price', 'BETWEEN', [10, 25]).get(); // operator form await Book.orWhereIn('genre', ['a', 'b']).orWhereNull('pages').get(); // Selection await Book.select('genre').distinct().orderBy('genre').get(); await Book.orderBy('price').pluck('title'); // ['Tiny Tales', ...] await Book.where('isbn', isbn).value('title'); // single value or null await Book.findMany([id1, id2]); await Book.latest().first(); // newest by createdAt (oldest() too) await Book.query().firstOrFail(); // Aggregates await Book.sum('price'); // 0 when nothing matches await Book.where('genre', 'science').avg('price'); // null when nothing matches await Book.min('price'); await Book.max('pages'); await Book.count('pages'); // counts non-null values await Book.where('genre', 'none').doesntExist(); // GROUP BY / HAVING -> plain rows await Book.query() .where('publishedAt', '>=', new Date('2024-01-01')) .groupBy( ['genre'], { total: ['sum', 'price'], books: ['count'], longest: ['max', 'pages'] }, { having: [['books', '>=', 2]], orderBy: [['total', 'DESC']], limit: 10 } ); // [{ genre: 'science', total: 42.5, books: 2, longest: 300 }, ...] // Atomic counters (no read-modify-write race) await Author.where('active', true).increment('logins'); // bulk; decrement() too await post.increment('views', 1); // single model, also updates `post.views` // Find-or-create / upsert-style helpers const tag = await Tag.firstOrCreate({ slug: 'news' }, { label: 'News' }); const setting = await Setting.updateOrCreate({ key: 'theme' }, { value: 'dark' }); // Batches await User.query().chunk(500, async (users, page) => { /* ... return false to stop */ }); // Row locks inside a transaction (PostgreSQL / MySQL; no-op on SQLite and MongoDB) await transaction(async () => { const account = await Account.where('id', id).lockForUpdate().first(); // ... }); // Soft-delete restore await Post.onlyTrashed().where('authorId', id).restore(); await post.restore(); // Escape hatch await User.whereRaw('LOWER(email) = ?', [email]).get(); // SQL databases await User.whereRaw({ tags: { $all: ['a', 'b'] } }).get(); // MongoDB ``` ### Eager loading (no N+1) ```ts const posts = await Post.query().with('author').orderBy('id', 'DESC').limit(10).get(); posts[0].author.email; // loaded with one extra query for all posts ``` ### Soft deletes With `softDelete: true`, `delete()` sets `deletedAt` and normal queries skip those rows. ```ts await Post.query().get(); // only rows not soft-deleted await Post.withTrashed().get(); // everything await Post.onlyTrashed().get(); // only soft-deleted await post.delete({ force: true }); // really delete ``` ### Transactions ```ts import { transaction } from 'jsango'; await transaction(async () => { const user = await User.create({ email: 'ada@example.com' }); await Profile.create({ userId: user.id }); // same transaction automatically if (!ok) throw new Error('rollback'); // any error rolls everything back }); // isolation level / specific connection await transaction( async () => { /* ... */ }, { isolationLevel: 'SERIALIZABLE', connection: 'analytics' } ); ``` Every model call inside the callback, including nested async functions, uses the transaction. You don't need to pass it around. A `transaction()` inside another one runs in a **savepoint**: if it throws, only its own changes are undone and the outer transaction can continue (catch the error to do so). MongoDB has no savepoints, so there a failed inner transaction rolls back the whole outer one. To use an explicit transaction or connection instead: `User.query().using(tx)`, `User.create(data, { connection: tx })`, `user.save({ connection: tx })`. --- ## 4a. MongoDB Models, queries, relations, transactions and migrations all work on MongoDB. Connect it like any other database: ```bash npm install mongodb # .env DATABASE_URL=mongodb://app:secret@localhost:27017/myapp # or MongoDB Atlas: mongodb+srv://app:secret@cluster0.xxxxx.mongodb.net/myapp ``` **Model ids.** Documents are keyed by `_id`. jsango maps your model's primary key to `_id` and exposes it as a 24-character hex string: ```ts export const Post = defineModel( 'Post', { id: fields.objectId({ primaryKey: true }), // a new ObjectId is generated on create title: fields.string(), authorId: fields.objectId(), // stored as an ObjectId reference tags: fields.json({ nullable: true }), // arrays / objects are stored natively }, { table: 'posts', // collection name timestamps: true, relations: { author: { type: 'belongsTo', target: 'Author', foreignKey: 'authorId' } }, } ); const post = await Post.create({ title: 'Hello', authorId: author.id }); post.id; // '65f1c2...' (ObjectId as a string) await Post.find(post.id); // strings are converted to ObjectId in queries ``` Use `fields.objectId()` for primary keys and references if a model must run on MongoDB and SQL databases (on SQL it becomes `VARCHAR(24)`). `fields.id()` also works on MongoDB, but the ids are ObjectId strings rather than numbers. **How queries are translated.** `where` becomes a filter (`$eq`, `$ne`, `$gt`, `$in`, `$regex` for `LIKE`, `$or`/`$and`/`$nor` for groups, with SQL precedence). `orderBy`, `limit` and `offset` map to `sort`, `limit` and `skip`. `count` uses `countDocuments`. Aggregates and `groupBy` run as aggregation pipelines, and `increment` uses `$inc`. `IS NULL` matches both missing and null fields, like an SQL NULL. **Transactions** need a **replica set** or a sharded cluster (Atlas always has one). On a standalone `mongod`, `transaction()` fails with a hint. For local development: ```bash mongod --replSet rs0 --dbpath ./data # then, once, in mongosh: rs.initiate() ``` Migrations don't need transactions and work on standalone servers too. **Migrations on MongoDB.** `makemigrations` and `migrate` work the same way as on SQL, and the operations are translated as follows: | Operation | MongoDB | | ------------------- | --------------------------------------------------------------------------------------------------------------------- | | create table | `createCollection` with a `$jsonSchema` validator (required fields + types, `validationLevel: moderate`) plus indexes | | unique / index | `createIndex` (unique indexes on nullable fields ignore missing values, like SQL NULLs) | | add column | backfills the default into existing documents, then updates the validator | | drop column | relaxes the validator, drops the indexes on it, then `$unset`s the field everywhere | | rename column | updates the validator, `$rename`s the field, and rebuilds the indexes on it | | alter column | updates the validator | | rename / drop table | `renameCollection` / `drop` | | foreign keys | skipped (MongoDB has none); relations are still resolved by the ORM | Migration history lives in the `jsango_migrations` collection, and the lock in `jsango_migration_lock`. For data migrations, use `ctx.execute()` (`ctx.sql()` isn't available): ```ts export default defineMigration({ id: '20260930130000_backfill_roles', async up(ctx) { await ctx.execute({ op: 'updateMany', collection: 'users', filter: { role: null }, update: { $set: { role: 'member' } }, }); await ctx.execute({ op: 'createIndex', collection: 'users', keys: { lastSeenAt: -1 }, name: 'idx_users_last_seen', }); }, async down(ctx) { await ctx.execute({ op: 'dropIndex', collection: 'users', name: 'idx_users_last_seen' }); }, }); ``` `t.objectIdKey()` and `t.objectId('authorId')` are the table-builder equivalents of the model fields. **Native access.** For change streams, GridFS, `$lookup`-heavy pipelines and similar, use the native driver: ```ts import type { Db } from 'mongodb'; const mongo = await db.mongo<Db>(); await mongo .collection('events') .aggregate([{ $match: { type: 'signup' } }]) .toArray(); // or structured commands through a pooled connection: const conn = await db.connection(); try { const { rows } = await conn.execute({ op: 'aggregate', collection: 'orders', pipeline: [{ $match: { status: 'paid' } }], }); } finally { await conn.release(); } ``` --- ## 5. Migrations ### The idea Your **models describe the schema you want**. **Migrations** are the ordered steps that take a real database there. jsango writes them for you: 1. `npx jsango makemigrations` replays your existing migration files to work out what the database _should_ look like now (this needs no database connection), compares that with your models, and writes a new file containing only the difference. 2. `npx jsango migrate` runs the pending files in order and records each one in the `jsango_migrations` table, so it never runs twice. Migration files are code: review them, commit them with the model change, and deploy them. ### Everyday workflow ```bash # 1. edit src/models/user.ts (e.g. add age: fields.integer({ nullable: true })) npx jsango makemigrations # or: npx jsango migrate:generate add_age_to_users # ✔ Created migration migrations/20260930101500_add_age_to_users.ts # [+] Add column users.age (integer) npx jsango migrate --dry-run # optional: show the exact SQL npx jsango migrate # apply npx jsango migrate:status # applied / pending list ``` ### What a generated migration looks like ```ts // migrations/20260930101500_add_age_to_users.ts import { AddColumnOperation, Migration } from 'jsango'; export const id = '20260930101500_add_age_to_users'; export const name = 'add_age_to_users'; export default new Migration({ id, name, operations: [new AddColumnOperation('users', { name: 'age', type: 'integer', nullable: true })], }); ``` Operation-based migrations are **reversible automatically**: `migrate:rollback` runs the inverse operations in reverse order. ### Commands | Command | What it does | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `jsango makemigrations [name]` | Create a migration from model changes (alias of `migrate:generate`). `--dry-run` shows the changes without writing a file; `--empty` creates a blank hand-written migration. | | `jsango migrate` | Apply pending migrations. `--dry-run` prints the SQL; `--target <id>` stops at a migration; `--yes` confirms destructive changes. | | `jsango migrate:status` | List applied and pending migrations. | | `jsango migrate:rollback` | Undo the last **batch** (everything applied by the last `migrate` run). `--steps 1` undoes just one migration; `--target <id>` undoes everything after `<id>`. | | `jsango migrate:check` | Exit with an error if models have changes that aren't in a migration, or if migrations are pending. Use it in CI. `--skip-db` compares files only. | | `jsango migrate:reset --yes [--fresh]` | Roll back all migrations; `--fresh` then re-applies them. Refused when `NODE_ENV=production`. | | `jsango db:status` | Test every configured connection. | All of them accept `--connection <name>` for multi-database projects, and `--json` for machine-readable output. ### Destructive changes Dropping a table or column, shrinking a column, or changing a column's type can lose data. The generator marks these operations `[destructive]`, and `migrate` refuses to run them until you confirm with `--yes`: ```bash npx jsango migrate --dry-run # look at what will be dropped npx jsango migrate --yes ``` > **Renames:** the generator can't tell a rename from "drop the old column + add a new one", > and dropping loses data. To rename, write the migration by hand with `ctx.renameColumn()` > (below) instead of generating it. Adding a **NOT NULL column without a default** to a table that already has rows fails on every database. The generator warns you about this; make the field `nullable: true`, or give it a `defaultValue`. ### Hand-written migrations For renames, data migrations, raw SQL, views or extensions: ```bash npx jsango makemigrations rename_user_name --empty ``` ```ts import { defineMigration } from 'jsango'; export default defineMigration({ id: '20260930120000_rename_user_name', async up(ctx) { await ctx.renameColumn('users', 'name', 'fullName'); await ctx.createTable('audit_logs', (t) => { t.id(); t.integer('userId').references('users'); // FK -> users.id, ON DELETE CASCADE t.string('action', 50).index(); t.json('payload').nullable(); t.timestamps(); }); // data migration (skipped while makemigrations replays history) await ctx.sql("UPDATE users SET role = 'member' WHERE role IS NULL"); }, async down(ctx) { await ctx.dropTable('audit_logs'); await ctx.renameColumn('users', 'fullName', 'name'); }, }); ``` Schema helpers on `ctx`: `createTable`, `dropTable`, `renameTable`, `addColumn`, `dropColumn`, `alterColumn`, `renameColumn`, `addIndex`, `dropIndex`, `addUnique`, `dropUnique`, `addForeignKey`, `dropForeignKey`, `sql`. Table builder columns: `id`, `bigId`, `string`, `text`, `integer`, `bigint`, `float`, `decimal`, `boolean`, `dateTime`, `date`, `time`, `json`, `uuid`, `binary`, `timestamps`, `softDeletes`. Column modifiers: `.nullable()`, `.default(v)`, `.unique()`, `.index()`, `.primaryKey()`, `.references(table, column?, { onDelete })`. Use the `ctx` schema helpers rather than `ctx.sql()` for schema changes, because `makemigrations` can see them when working out the current schema. Changes made through `ctx.sql()` are invisible to it. ### How each database runs migrations | | PostgreSQL | SQLite | MySQL / MariaDB | MongoDB | | ------------------------------------ | ----------------- | ----------------------------------------- | -------------------------------------------------- | ------------------------------------------------ | | Each migration in a transaction | ✅ | ✅ | ❌ DDL auto-commits | ❌ | | Failure mid-migration | fully rolled back | fully rolled back | earlier statements stay applied; the error says so | earlier commands stay applied; the error says so | | `ALTER COLUMN`, add/drop foreign key | native | table rebuild (automatic, data preserved) | native | validator update; FKs skipped | - **SQLite** can't alter columns or add/drop foreign keys in place. jsango rebuilds the table: it creates a copy with the new definition, copies the rows, swaps the tables and recreates the indexes. It reads the table's live definition first, so no columns are lost, and runs with foreign keys paused plus an integrity check before committing. - **MySQL** can't roll back DDL. Keep MySQL migrations small (one logical change each) so a failure is easy to fix. - A **migration lock** (`jsango_migration_lock` table) stops two deploys from migrating at the same time. ### Team workflow - File names start with a UTC timestamp, so migrations from different branches sort deterministically. After merging, run `npx jsango migrate:check` to see whether the models still need a migration. - Don't edit a migration that has already run in shared environments. Add a new one instead. - Commit the migration in the same commit as the model change. ### Running migrations in production Run migrations as a release step before starting the new version: ```bash npm ci npx jsango migrate:check --skip-db # optional: fail the build if a migration is missing npx jsango migrate # applies pending migrations, locked against concurrent runs npm start ``` The migrations folder and `jsango.config.ts` must be deployed with the app. `tsx` ships with the CLI, so TypeScript migrations run without a build step. --- ## 6. Production checklist - [ ] Use PostgreSQL or MySQL. Use SQLite only for single-server apps with modest write traffic. - [ ] Put credentials in `DATABASE_URL`, not in code. Enable `DATABASE_SSL=true` for managed databases. - [ ] Size the pool (`DATABASE_POOL_MAX`) so that `instances × pool size` stays below the server's `max_connections`. - [ ] Call `await db.verify()` before `listen()` so a bad configuration fails at startup. (Without any database configured, models throw in production instead of using a throwaway in-memory database.) - [ ] Set `DATABASE_QUERY_TIMEOUT=30000` (or `queryTimeoutMs` in code) so a hung query can't hold a pooled connection forever; long reports can pass `{ timeoutMs }` per query. - [ ] Call `await db.close()` on `SIGTERM`. - [ ] Run `npx jsango migrate` in the release pipeline, and `migrate:check` in CI. - [ ] Back up the database before any migration marked `[destructive]`. - [ ] Monitor `GET /health/database` (the generated app exposes it). --- ## 7. Troubleshooting | Message | Fix | | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | | `No database is configured for this project` | Add `jsango.config.ts` (see [section 2](#2-jsangoconfigts)) or set `DATABASE_URL` in `.env`. | | `PostgreSQL support requires the 'pg' package` | `npm install pg` (or `mysql2` / `better-sqlite3` / `mongodb`). | | `MongoDB transactions need a replica set` | Start `mongod --replSet rs0` and run `rs.initiate()` once, or use Atlas. | | `Document failed validation` (MongoDB) | A document is missing a required (non-nullable) field or has the wrong type. Make the field `nullable: true` or pass a value. | | `MongoDB connections do not run SQL` | Use models, `conn.execute({...})` or `db.mongo()` instead of `db.query('SELECT ...')`. | | `SQLite support requires 'better-sqlite3' or Node.js 22.13+` | Upgrade Node.js, or run `npm install better-sqlite3`. | | `ECONNREFUSED` | The database server isn't running, or the host/port is wrong. | | `password authentication failed` / `ER_ACCESS_DENIED_ERROR` | Wrong user or password. URL-encode special characters in `DATABASE_URL`. | | `database "x" does not exist` / `ER_BAD_DB_ERROR` | Create the database first: `CREATE DATABASE x;` | | `no such table: users` / `relation "users" does not exist` | You haven't run `npx jsango makemigrations` and `npx jsango migrate` yet. | | `Migration run contains destructive changes` | Check with `migrate --dry-run`, then run `migrate --yes`. | | `No changes detected` but the table is wrong | The table was changed outside migrations. Run `migrate:status`, then fix the table with a hand-written migration. | | `Could not acquire migration lock` | Another `migrate` is running. A crashed run's lock expires after 15 minutes. | | `Model with name 'X' is already registered` | Two different models share a name. Model names must be unique. | --- ## 8. Limitations - **MongoDB:** raw SQL (`whereRaw('…')`, `db.query('SELECT …')`) isn't available. Use relations (`.with()`), `whereRaw({ …filter })`, `conn.execute()` or `db.mongo()` instead. Savepoints (nested transactions) aren't supported, and the database doesn't enforce foreign keys. - `whereLike(…, { caseSensitive: true })` is exact-case only on PostgreSQL and MongoDB. SQLite `LIKE` is always case-insensitive for ASCII, and MySQL follows the column collation (case-insensitive by default). - `makemigrations` doesn't detect renames. Use a hand-written `ctx.renameColumn()` / `ctx.renameTable()` migration. - SQLite table rebuilds keep columns, keys, indexes and unique constraints. `CHECK` constraints and triggers written by hand in raw SQL aren't recreated. - MySQL doesn't return inserted rows, so after `bulkCreate()` on MySQL the returned models have no `id`s. `create()` does return the `id`. - The default table name is the lower-cased model name plus `s` (`Category` → `categorys`). Set `table` explicitly for good names. --- <!-- source: docs/auth/README.md (Authentication guide) --> # Authentication Guide `createAuth()` gives you complete authentication in one object, with secure defaults: | Method | Use it for | | ------------------------------------------------------------------------------------------- | --------------------------------------------------- | | [Password login + JWT](#3-tokens-for-apis-and-mobile-apps) | APIs, SPAs, mobile apps | | [Cookie sessions](#5-cookie-sessions-for-web-apps) | Server-rendered / same-site web apps | | [API keys](#6-api-keys) | Server-to-server integrations, CLI tools | | [Social login (OAuth2)](#7-social-login-google-github-) | "Sign in with Google / GitHub / …" | | [External identity providers](#8-external-identity-providers-auth0-clerk-cognito-firebase-) | Auth0, Clerk, Cognito, Firebase, Azure AD, Keycloak | | [Two-factor (TOTP)](#9-two-factor-authentication-totp) | Authenticator-app codes on top of passwords | All of them are checked by the same middleware, `auth.required()`, so one route can accept a session cookie from your web app, a bearer token from your mobile app, and an API key from a partner at the same time. - [1. Quick start](#1-quick-start) - [2. Users and passwords](#2-users-and-passwords) - [3. Tokens (for APIs and mobile apps)](#3-tokens-for-apis-and-mobile-apps) - [4. Protecting routes](#4-protecting-routes) - [5. Cookie sessions (for web apps)](#5-cookie-sessions-for-web-apps) - [6. API keys](#6-api-keys) - [7. Social login (Google, GitHub, …)](#7-social-login-google-github-) - [8. External identity providers](#8-external-identity-providers-auth0-clerk-cognito-firebase-) - [9. Two-factor authentication (TOTP)](#9-two-factor-authentication-totp) - [10. Production checklist](#10-production-checklist) - [11. What jsango protects you from](#11-what-jsango-protects-you-from) --- ## 1. Quick start ```ts // src/auth.ts import { createAuth, DatabaseAuthStore } from 'jsango'; import { User } from './models/user.js'; import { db } from './database.js'; export const auth = createAuth({ secret: process.env.AUTH_SECRET!, // at least 32 characters: `openssl rand -base64 48` users: { findById: (id) => User.find(id), findByLogin: (email) => User.where('email', email).first(), }, store: new DatabaseAuthStore({ connection: db }), // sessions & tokens survive restarts }); ``` ```ts // src/index.ts import { createApp } from 'jsango'; import { auth } from './auth.js'; const app = createApp(); app.post('/auth/login', async (ctx) => { const { email, password } = (await ctx.request.json()) as { email: string; password: string }; return auth.login(email, password, ctx); // => { tokenType: 'Bearer', accessToken, expiresIn: 900, refreshToken } }); app.get('/me', auth.required(), async (ctx) => { const user = await auth.user(ctx); return { id: user?.id, email: user?.email }; }); ``` ```bash curl -X POST localhost:3000/auth/login -H 'content-type: application/json' \ -d '{"email":"ada@example.com","password":"correct horse"}' curl localhost:3000/me -H "Authorization: Bearer <accessToken>" ``` Errors become JSON responses automatically: `401` when not authenticated, `403` for a missing role or permission, and `429` when locked out. --- ## 2. Users and passwords jsango doesn't own your user table. You tell it how to load users, and it reads these fields from them (configurable): | Field | Purpose | Option to rename | | ----------------- | --------------------------------------- | ---------------- | | `id` | User id (stored in tokens and sessions) | `identity` | | `passwordHash` | scrypt hash from `auth.hashPassword()` | `password.field` | | `role` or `roles` | Roles for `auth.required({ roles })` | `identity` | | `permissions` | Extra permissions | `identity` | | `tenantId` | Multi-tenant apps | `identity` | | `isSuperuser` | Bypasses role/permission checks | `identity` | | `totpSecret` | Enables two-factor login | `mfa.field` | ```ts export const User = defineModel( 'User', { id: fields.id(), email: fields.string({ maxLength: 255, unique: true }), passwordHash: fields.string({ nullable: true }), // null for social-login-only users role: fields.string({ maxLength: 20, defaultValue: 'member' }), totpSecret: fields.string({ nullable: true }), isActive: fields.boolean({ defaultValue: true }), }, { table: 'users', timestamps: true } ); ``` **Sign-up:** ```ts app.post('/auth/register', async (ctx) => { const { email, password } = (await ctx.request.json()) as { email: string; password: string }; const user = await User.create({ email: email.trim().toLowerCase(), passwordHash: await auth.hashPassword(password), // rejects < 8 characters (422) }); return auth.issueTokens(user); // sign them in right away }); ``` **Blocking users.** Add `users.isActive` and disabled users can neither log in nor refresh tokens. Their cookie sessions also stop working immediately: ```ts createAuth({ secret: process.env.AUTH_SECRET!, users: { findById: (id) => User.find(id), findByLogin: (email) => User.where('email', email).first(), isActive: (user) => user.isActive && !user.bannedAt, }, }); ``` **Upgrading hashes.** When you raise the scrypt cost, `password.onRehash` receives a new hash on each user's next successful login: ```ts createAuth({ // ...secret, users password: { onRehash: (user, hash) => User.query().where('id', user.id).update({ passwordHash: hash }), }, }); ``` **Changing a password:** save the new hash, then sign the user out everywhere with `await auth.logoutAll(user.id)`. --- ## 3. Tokens (for APIs and mobile apps) `auth.login()` returns a short-lived **access token** (JWT, 15 minutes) and a long-lived **refresh token** (30 days): ```ts // Client: send the access token on every request // Authorization: Bearer <accessToken> // When it expires (401 with code ERR_AUTH_TOKEN_EXPIRED), exchange the refresh token: app.post('/auth/refresh', async (ctx) => { const { refreshToken } = (await ctx.request.json()) as { refreshToken: string }; return auth.refresh(refreshToken); // new accessToken AND new refreshToken }); app.post('/auth/logout', auth.required(), async (ctx) => { await auth.logout(ctx); // the access token stops working now, not in 15 minutes return { ok: true }; }); ``` - **Rotation:** every `refresh()` returns a new refresh token, and the old one stops working. - **Reuse detection:** if an old refresh token is presented again (it was stolen and both the thief and the user try to use it), the whole login is revoked and both must sign in again. - **`auth.logoutAll(userId)`** revokes every token and session of a user, on all devices. - **`auth.revokeRefreshToken(token)`** is for clients signing out without an access token. The returned object has a non-enumerable `user` property (`result.user` in your code), so returning the result from a handler never sends the user record, including its password hash, to the client. Options: `tokens: { accessTtl: 900, refreshTtl: 2592000, issuer: 'jsango', audience: 'my-api' }`. Access tokens carry `sub`, `roles`, `permissions` and `tenantId`. A role change therefore takes effect when the access token is refreshed (at most 15 minutes later). Call `logoutAll()` to force it immediately. --- ## 4. Protecting routes ```ts app.get('/me', auth.required(), handler); // any signed-in user app.get('/admin', auth.required({ roles: ['admin'] }), handler); // any of these roles app.delete('/posts/:id', auth.required({ permissions: ['posts.delete'] }), handler); // all of these app.get('/feed', auth.optional(), handler); // anonymous allowed app.post('/webhook', auth.required({ methods: ['apiKey'] }), handler); // only API keys here ``` Map roles to permissions once: ```ts createAuth({ // ... roles: { admin: ['posts.update', 'posts.delete', 'users.manage'], editor: ['posts.update'], }, }); ``` Inside handlers: ```ts const identity = auth.identity(ctx); // { id, roles, permissions, tenantId, hasRole(), hasPermission() } const user = await auth.user(ctx); // your user record (loaded once per request), null if anonymous ``` `auth.optional()` still rejects credentials that are present but invalid. A bad or expired token never silently turns into anonymous access. For per-object rules ("users can only edit their own posts"), check in the handler or use policies with `authorize()` (`docs/architecture/auth/policies.md`). They read the same identity. --- ## 5. Cookie sessions (for web apps) ```ts app.post('/login', async (ctx) => { const { email, password } = (await ctx.request.json()) as { email: string; password: string }; const result = await auth.login(email, password, ctx); if (result.mfaRequired) return result; // see section 9 return auth.startSession(HttpResponse.json({ ok: true }), result.user); }); app.post('/logout', auth.required(), async (ctx) => { const response = HttpResponse.json({ ok: true }); await auth.logout(ctx, response); // deletes the session and clears the cookie return response; }); ``` - The cookie holds only a random, HMAC-signed session id. It's `HttpOnly`, `SameSite=Lax`, and `Secure` in production. Session data stays on the server, and every login gets a new id. - Sessions slide: they last `session.ttl` (7 days) after the last activity. - **CSRF protection:** cookie-authenticated `POST/PUT/PATCH/DELETE` requests must carry an `Origin` (or `Referer`) from a trusted origin. Bearer tokens and API keys are not affected. By default only the API's own origin is trusted. If your frontend runs on another origin, list it: ```ts createAuth({ // ...secret, users session: { trustedOrigins: ['https://app.example.com'], sameSite: 'None', // only when frontend and API are on different sites (requires HTTPS) domain: '.example.com', }, }); ``` Each request re-loads the user, so blocking a user or changing their role applies immediately. --- ## 6. API keys ```ts createAuth({ // ... apiKeys: { find: async (hash) => { const key = await ApiKey.where('hash', hash).whereNull('revokedAt').first(); return key ? User.find(key.userId) : null; // the owner the request acts as }, }, }); app.post('/settings/api-keys', auth.required(), async (ctx) => { const { key, hash, prefix, last4 } = auth.createApiKey('live'); // live_9fKx... await ApiKey.create({ userId: auth.identity(ctx).id, hash, prefix, last4 }); return { key }; // shown once, never stored in plain text }); ``` Clients send `X-API-Key: live_...` or `Authorization: ApiKey live_...`. Only the SHA-256 hash is ever stored or looked up. To revoke a key, set `revokedAt`. --- ## 7. Social login (Google, GitHub, …) ```ts import { createAuth, google, github, oauthProvider, HttpResponse } from 'jsango'; export const auth = createAuth({ secret: process.env.AUTH_SECRET!, users: { findById: (id) => User.find(id) }, oauth: [ google({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, redirectUri: 'https://api.example.com/auth/google/callback', }), github({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, redirectUri: 'https://api.example.com/auth/github/callback', }), ], }); app.get('/auth/:provider', (ctx) => auth.oauth.redirect(ctx.request.params['provider']!)); app.get('/auth/:provider/callback', async (ctx) => { const profile = await auth.oauth.callback(ctx.request.params['provider']!, ctx); // { provider, id, email, emailVerified, name, avatarUrl } let account = await SocialAccount.where({ provider: profile.provider, providerId: profile.id, }).first(); let user = account ? await User.find(account.userId) : null; if (!user) { if (!profile.email || !profile.emailVerified) return HttpResponse.badRequest('A verified email is required.'); user = await User.firstOrCreate({ email: profile.email.toLowerCase() }, { name: profile.name }); await SocialAccount.create({ provider: profile.provider, providerId: profile.id, userId: user.id, }); } return auth.startSession(HttpResponse.redirect('https://app.example.com/'), user); }); ``` The flow uses the authorization code with **PKCE**, and a signed, HttpOnly `state` cookie that expires after 10 minutes. Forged or replayed callbacks are rejected with 400. Only link accounts by email when `emailVerified` is true. Any other OAuth2 / OpenID Connect provider: ```ts oauthProvider({ name: 'microsoft', clientId: process.env.MS_CLIENT_ID!, clientSecret: process.env.MS_CLIENT_SECRET!, redirectUri: 'https://api.example.com/auth/microsoft/callback', authorizeUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', userInfoUrl: 'https://graph.microsoft.com/oidc/userinfo', }); ``` --- ## 8. External identity providers (Auth0, Clerk, Cognito, Firebase, …) If users sign in through a hosted provider, jsango verifies the provider's tokens with its public keys (JWKS). RS256/384/512, PS256/384/512, ES256/384/512 and EdDSA are supported. Shared-secret algorithms are refused, so tokens can't be forged. ```ts createAuth({ // ... external: { jwksUrl: 'https://YOUR_TENANT.auth0.com/.well-known/jwks.json', issuer: 'https://YOUR_TENANT.auth0.com/', audience: 'https://api.example.com', identity: (claims) => ({ id: String(claims.sub), roles: (claims['https://example.com/roles'] as string[]) ?? [], }), }, }); ``` | Provider | `jwksUrl` | `issuer` | | ----------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------- | | Auth0 | `https://TENANT.auth0.com/.well-known/jwks.json` | `https://TENANT.auth0.com/` | | Clerk | `https://YOUR_FRONTEND_API/.well-known/jwks.json` | `https://YOUR_FRONTEND_API` | | AWS Cognito | `https://cognito-idp.REGION.amazonaws.com/POOL_ID/.well-known/jwks.json` | `https://cognito-idp.REGION.amazonaws.com/POOL_ID` | | Firebase | `https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com` | `https://securetoken.google.com/PROJECT_ID` | | Keycloak | `https://HOST/realms/REALM/protocol/openid-connect/certs` | `https://HOST/realms/REALM` | Keys are cached for 10 minutes and refetched when the provider rotates them (fetches time out after 5 seconds). Expiry, `iss` and `aud` are always checked. **`audience` is required**: without it, a token the same provider issued for any other app (e.g. any "Sign in with Google" site) would be accepted. Pass `audience: false` only if your provider issues none. Your own tokens and the provider's can be accepted side by side. --- ## 9. Two-factor authentication (TOTP) **Enrollment** (authenticator apps such as Google Authenticator or 1Password): ```ts app.post('/account/2fa/setup', auth.required(), async (ctx) => { const user = (await auth.user(ctx))!; const { secret, uri } = auth.totp.generateSecret({ issuer: 'My App', accountName: user.email }); await User.query().where('id', user.id).update({ pendingTotpSecret: secret }); return { uri }; // render as a QR code in the browser (e.g. the `qrcode` npm package) }); app.post('/account/2fa/confirm', auth.required(), async (ctx) => { const { code } = (await ctx.request.json()) as { code: string }; const user = (await auth.user(ctx))!; if (!auth.totp.verifyToken(code, user.pendingTotpSecret)) return HttpResponse.badRequest('Invalid code'); await User.query() .where('id', user.id) .update({ totpSecret: user.pendingTotpSecret, pendingTotpSecret: null }); return { backupCodes: auth.totp.generateBackupCodes(8) }; // store hashed, show once }); ``` Render the QR code yourself. `qrCodeUrl` is a convenience that sends the secret to a third-party QR service, so don't use it in production. **Login.** For users with a `totpSecret`, `login()` returns a challenge instead of tokens: ```ts app.post('/auth/login', async (ctx) => { const { email, password } = (await ctx.request.json()) as { email: string; password: string }; return auth.login(email, password, ctx); // => { mfaRequired: true, mfaToken } for 2FA users }); app.post('/auth/login/2fa', async (ctx) => { const { mfaToken, code } = (await ctx.request.json()) as { mfaToken: string; code: string }; return auth.verifyMfa(mfaToken, code); // => tokens; use startSession(...) for cookie apps }); ``` The `mfaToken` is valid for 5 minutes and can't be used as an access token. Codes allow ±30 seconds of clock drift, work only once, and are brute-force limited. --- ## 10. Production checklist - [ ] **`AUTH_SECRET`**: 32+ random characters from your secret manager (`openssl rand -base64 48`). Rotating it signs everyone out. - [ ] **`store: new DatabaseAuthStore({ connection: db })`**: the in-memory store (default) loses sessions and refresh tokens on restart and isn't shared between instances; jsango warns at startup in production. You can also implement `AuthStore` on Redis (`get`, `set`, `delete`, `increment` with TTLs). - [ ] **HTTPS everywhere.** Cookies are `Secure` when `NODE_ENV=production`. - [ ] Behind a proxy or load balancer, make sure `ctx.request.ip` is the client IP (brute-force limits are per account and per IP). - [ ] Call `auth.logoutAll(user.id)` after password changes and account recovery. - [ ] Validate login / registration bodies (`validate({ body: ... })`) and rate-limit sign-up. - [ ] Never return user records with `passwordHash` or `totpSecret`. Token results already hide `user`; for your own endpoints, return only the fields the client needs. --- ## 11. What jsango protects you from | Threat | Protection | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | Password database leak | scrypt (memory-hard) with per-password salt; automatic rehash on login | | Online password guessing | 5 failures per account per 15 minutes (20 per IP) → `429` | | Account enumeration | One generic error message; unknown users cost the same hashing time | | Stolen access token | 15-minute lifetime; `logout()` revokes it immediately | | Stolen refresh token | Rotation on every use (atomic: of two concurrent refreshes only one wins); reuse more than a minute later revokes the whole login | | JWT forgery / algorithm confusion | HMAC key derived from your secret, `alg` pinned to HS256; external tokens accept asymmetric algorithms only; `none` is rejected | | Tokens that never expire | Every token has `exp`; tokens without one are rejected | | Session fixation / cookie tampering | New random id per login; HMAC-signed cookie; HttpOnly + SameSite | | CSRF on cookie sessions | Trusted `Origin` / `Referer` required for state-changing requests | | OAuth login CSRF / code interception | Signed one-time `state` + PKCE (S256) | | TOTP brute force / replay | Attempt limit + each code accepted once (also with spaces added) | | Disabled user keeps access | `isActive` checked on login, refresh, API keys and every session request | | Tokens from other apps (external IdP) | `audience` is required for external tokens | | Leaking the user record | Token results never serialize `user` | | API key database leak | Only SHA-256 hashes are stored and compared | **Lower-level building blocks** are still available when you need full control: `JwtService`, `ScryptPasswordHasher`, `TotpService`, the `authenticate()` / `authorize()` middleware with custom strategies, roles, permissions and policies. See `docs/architecture/auth/`. --- <!-- source: docs/admin/ADMIN-CUSTOMIZATION.md (Admin panel guide) --> # Admin Panel Guide `app.admin()` gives your app a ready-made admin panel: CRUD screens generated from your models, dashboards and custom pages built from widgets, CSV export, an audit trail and a system page. It is built for large tables (millions of rows) and is safe to expose in production. - [1. Quick start](#1-quick-start) - [2. Who can sign in](#2-who-can-sign-in) - [3. Customizing a model's screens](#3-customizing-a-models-screens) - [4. Dashboards](#4-dashboards) - [5. Custom pages](#5-custom-pages) - [6. Actions](#6-actions) - [7. Millions of rows](#7-millions-of-rows) - [8. Permissions](#8-permissions) - [9. Audit trail, health checks and branding](#9-audit-trail-health-checks-and-branding) - [10. Security](#10-security) - [11. HTTP API](#11-http-api) --- ## 1. Quick start ```ts import { createApp, createAuth } from 'jsango'; import { User, Order, Product } from './models/index.js'; const app = createApp(); app.admin({ title: 'Shop Admin', auth: createAuth({ secret: process.env.AUTH_SECRET!, users: { findById: (id) => User.find(id), findByLogin: (email) => User.where('email', email).first(), }, }), resources: [User, Order, Product], }); await app.listen(3000); // http://localhost:3000/admin ``` Every model gets a list screen (search, filters, sorting, paging, bulk delete, CSV export/import) and a create/edit form. Field types, required fields, read-only fields (`id`, `createdAt`, `updatedAt`) and secrets (`password`, `token`, ... are hidden) are taken from the model. --- ## 2. Who can sign in **Your application's users (recommended).** Pass your `createAuth()` instance as `auth`. Passwords, lockout after failed attempts, two-factor login and `isActive` come from your auth setup ([authentication guide](../auth/README.md)). A user can enter the admin when they have: - `isSuperuser: true`, or - the `admin` or `staff` role, or - the `admin.access` permission. Other users get `403`. **A single built-in super admin** (small projects, internal tools): ```ts app.admin({ auth: { email: 'ops@example.com', password: process.env.ADMIN_PASSWORD }, resources: [Order], }); ``` You can also set `JSANGO_ADMIN_EMAIL` and `JSANGO_ADMIN_PASSWORD`. Without a password, the development default `admin123` works only with `NODE_ENV=development` or `test`; everywhere else (including a server started without `NODE_ENV`) **login is refused until you set a password of 12+ characters**. Admin logins last 8 hours (`sessionTtlSeconds`) and end after an hour without activity. App users' roles, bans (`isActive`) and `auth.logoutAll()` are re-checked every minute, so removing someone's admin role signs them out of the admin too. The **Profile & Security** page lists the account's real sessions and can sign out other devices. For the built-in account it also changes the password (the current password is required, 12+ characters) and turns two-factor authentication on or off. ### Several instances / restarts Sessions, login lockouts, export links and the built-in account's 2FA and changed password live in a store. With `auth: createAuth(...)` it is your auth store; otherwise it is memory (one instance, reset on restart). To share them between instances behind a load balancer: ```ts import { DatabaseAuthStore } from 'jsango'; app.admin({ store: new DatabaseAuthStore({ connection: db }), resources: [Order] }); ``` --- ## 3. Customizing a model's screens Pass `{ model, ...options }`. Anything you don't set still comes from the model, so you only write what you want to change: ```ts app.admin({ resources: [ { model: Order, label: 'Order', navigationGroup: 'Sales', navigationIcon: 'shopping-bag', listFields: ['id', 'customer', 'total', 'status', 'createdAt', 'summary'], searchFields: ['customer', 'email'], defaultSortField: 'createdAt', defaultSortDirection: 'desc', defaultPageSize: 50, fields: [ // override one field... { name: 'status', enumChoices: [ { label: 'Paid', value: 'paid' }, { label: 'Refunded', value: 'refunded' }, ], filterable: true, }, // ...or add a computed column { name: 'summary', type: 'computed', computedGetter: (o) => `${o['customer']} · ₹${o['total']}`, }, ], }, User, // plain models still work ], }); ``` | Option | What it controls | | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `label`, `pluralLabel` | Names in the sidebar and headings | | `navigationGroup`, `navigationIcon`, `navigationOrder` | Sidebar placement | | `listFields` | Columns of the list (and of the CSV export) | | `detailFields`, `createFields`, `editFields` | What the detail page and the forms show and accept. Anything else in a request body is ignored, which prevents mass assignment | | `searchFields` | Columns matched by the search box (case-insensitive contains) | | `filters` | Filter dropdowns. Default: boolean fields and fields with `enumChoices` marked `filterable` | | `fields` | Per-field overrides by name: `label`, `type`, `hidden`, `readonly`, `sensitive`, `sortable`, `filterable`, `enumChoices`, `description`, `computedGetter` | | `defaultSortField`, `defaultSortDirection`, `defaultPageSize`, `maxPageSize` | Default ordering and paging (`maxPageSize` caps `?pageSize=`) | | `actions`, `bulkActions` | Custom buttons (see [Actions](#6-actions)) | | `exactCount`, `exportBatchSize` | Large-table tuning (see [Millions of rows](#7-millions-of-rows)) | `new AdminResource({ modelName: 'Order', ... })` works too and also inherits the model's fields. ### Relations A `belongsTo` relation turns its foreign key column into a dropdown of the related records (register both models in `resources`): ```ts const Product = defineModel( 'Product', { id: fields.id(), name: fields.string(), categoryId: fields.integer() }, { relations: { category: relations.belongsTo(() => Category, { foreignKey: 'categoryId' }) }, } ); app.admin({ resources: [Product, Category] }); ``` The dropdown loads options once, shows a search box when there are more than 100, and has **+ New** and **Edit** buttons that open the related record's form in a side panel; a record saved there is selected straight away. `hasMany` / `manyToMany` relations are not editable in the form. ### Files, images and the media library Mark a column as `image` or `file` to get an upload box and a **Library** picker. The file is stored on the first media disk and the column keeps its URL: ```ts app.admin({ resources: [{ model: Product, fields: [{ name: 'image', type: 'image' }] }] }); ``` **Media** in the sidebar lists, uploads (several at once, or drag and drop), previews and deletes files, with a tab per disk: ```ts import { LocalDiskMediaStorage, S3MediaStorage, AdminMediaManager } from 'jsango'; app.admin({ media: { local: new LocalDiskMediaStorage({ root: './uploads', publicUrl: '/media' }), // served by the app s3: new S3MediaStorage({ bucket: 'assets', region: 'ap-south-1', accessKeyId: process.env.S3_KEY!, secretAccessKey: process.env.S3_SECRET!, }), r2: new S3MediaStorage({ bucket: 'assets', region: 'auto', endpoint: 'https://<account>.r2.cloudflarestorage.com', // also MinIO, Spaces, Backblaze B2 publicUrl: 'https://cdn.example.com', // optional; without it previews use signed URLs accessKeyId: process.env.R2_KEY!, secretAccessKey: process.env.R2_SECRET!, }), // upload rules avatars: new AdminMediaManager({ storage: new LocalDiskMediaStorage({ root: './uploads/avatars', publicUrl: '/avatars' }), validation: { maxSizeBytes: 2 * 1024 * 1024, allowedMimeTypes: ['image/*'] }, }), }, }); ``` Without `media`, there is one `local` disk (`./uploads`, served at `/media`); `media: {}` turns the library off. Local files are served with `nosniff` and a sandboxing CSP, so an uploaded HTML or SVG file cannot run scripts on your site. The S3 driver signs requests itself (no AWS SDK), works only inside its `root` folder (default `uploads`, so a bucket shared with private files stays private), serves anything but images, video, audio and PDF as a download, and lists the first 1000 files. The file type always comes from the file extension, never from the browser. --- ## 4. Dashboards The dashboard is made of widgets. Every widget loads its own data (in parallel), so one slow query never blocks the page: ```ts import { createApp, MetricWidget, ChartWidget, TableWidget, ActivityWidget } from 'jsango'; const app = createApp(); app.admin({ resources: [Order], dashboard: [ new MetricWidget({ title: 'Orders today', cacheSeconds: 60, // cache expensive queries for every viewer getValue: async () => ({ value: await Order.where('createdAt', '>=', startOfDay()).count(), change: 12.5, hint: 'vs yesterday', }), }), new MetricWidget({ title: 'Revenue', permission: 'finance.view', getValue: async () => `₹${await Order.query().sum('total')}`, }), new ChartWidget({ title: 'Orders by status', chartType: 'bar', // or 'line' width: 'half', refreshIntervalSeconds: 60, cacheSeconds: 60, getChartData: async () => { const rows = await Order.query().groupBy(['status'], { orders: ['count'], revenue: ['sum', 'total'], }); return { labels: rows.map((r) => String(r['status'])), datasets: [{ label: 'Orders', data: rows.map((r) => Number(r['orders'])) }], }; }, }), new TableWidget({ title: 'Latest orders', width: 'full', getTableData: async () => ({ headers: ['#', 'Customer', 'Total'], rows: (await Order.query().latest().limit(5).get()).map((o) => [o.id, o.customer, o.total]), }), }), new ActivityWidget({ title: 'Signups', getActivity: async () => (await User.query().latest().limit(10).get()).map((u) => ({ id: String(u.id), title: u.email, timestamp: u.createdAt.getTime(), })), }), ], }); declare function startOfDay(): Date; ``` | Option (every widget) | Meaning | | ------------------------ | ------------------------------------------------------------------------------------------------------------ | | `title`, `description` | Card heading (the id defaults to a slug of the title) | | `width` | `'quarter'` (metrics default), `'third'`, `'half'` (default), `'full'` | | `permission` | Only identities with this permission (or superusers) see it | | `cacheSeconds` | Server-side cache shared by all viewers; concurrent requests share one query. Don't use it for per-user data | | `refreshIntervalSeconds` | The browser reloads the widget on this interval | Getters receive `{ identity }`, so a widget can show per-user data: `getValue: ({ identity }) => Order.where('ownerId', identity.id).count()`. A metric value is a number, a string, or `{ value, change, trend, hint }`. Charts take up to eight series on one shared axis, with a legend and a hover tooltip, in light and dark mode. Without widgets, the dashboard shows the recent admin activity. --- ## 5. Custom pages A page is a dashboard of its own, with a sidebar link: ```ts import { AdminPage, MetricWidget, ChartWidget } from 'jsango'; const finance = new AdminPage({ id: 'finance', label: 'Finance', description: 'Revenue and refunds', navigationGroup: 'Reports', permission: 'finance.view', // hidden for everyone else widgets: [ new MetricWidget({ title: 'Refunds this month', getValue: () => 12 }), new ChartWidget({ title: 'Revenue', chartType: 'bar', getChartData: () => ({ labels: ['Jan', 'Feb'], datasets: [{ label: 'Revenue', data: [120, 180] }], }), }), ], }); // app.admin({ resources: [...], pages: [finance] }); ``` --- ## 6. Actions ```ts import { createApp } from 'jsango'; const app = createApp(); app.admin({ resources: [ { model: Order, actions: [ { id: 'refund', label: 'Refund', permission: 'orders.refund', requiresConfirmation: true, handler: async ({ item, actor }) => { await refund(item['id'] as number, actor); return 'Refunded'; }, }, ], bulkActions: [ { id: 'mark-shipped', label: 'Mark as shipped', handler: async ({ ids }) => Order.query().whereIn('id', ids).update({ status: 'shipped' }), }, ], }, ], }); declare function refund(id: number, actor: unknown): Promise<void>; ``` Every list also has a built-in **bulk delete**. It deletes the selected rows (up to 1,000) in one query after checking `canDelete` for each of them. --- ## 7. Millions of rows The admin only ever loads one page: - **Only the list columns are read** (`listFields` plus the primary key), not whole rows. - **Stable paging.** Every sort has a primary-key tie-breaker, so rows never repeat or vanish between pages. - **Parallel count.** `COUNT(*)` and the page query run at the same time. On tables where even that is too slow, set `exactCount: false`: the list then pages with next/previous only, using `LIMIT n+1` and no count. - **CSV export streams.** Every matching row (with the same search and filters) is exported in `exportBatchSize` batches (1,000 by default), using keyset pagination (`WHERE id > last`). Batch 1,000 costs the same as batch 1, and memory stays flat. The browser downloads it natively through a one-time link. - **Search waits for typing to stop** (300 ms) and is limited to `searchFields`. Search is a case-insensitive "contains" match. On very large tables, index those columns (or use a full-text index) and keep `searchFields` short. - **Sort and filter only on indexed columns** for large tables. Unknown or non-sortable sort fields are ignored, and `pageSize` is capped by `maxPageSize`. - **Dashboard widgets load independently**, in parallel. Use `cacheSeconds` for expensive aggregates. ```ts const events = { model: Event, listFields: ['id', 'type', 'userId', 'createdAt'], searchFields: ['type'], exactCount: false, defaultSortField: 'id', defaultSortDirection: 'desc' as const, }; // app.admin({ resources: [events] }); ``` The same keyset batching powers the ORM: `Model.query().chunk(1000, fn)` and `.cursor()` (without an `orderBy()`) now seek by primary key instead of `OFFSET`. --- ## 8. Permissions Superusers and users with the `admin` role can do everything. Users let in by the `staff` role or the `admin.access` permission can do **nothing until you grant it** (the resource id is the lower-cased model name): | Permission | Allows | | ------------------------------------------------------------ | -------------------------------------------- | | `admin.access` (or role `admin` / `staff`) | Entering the admin | | `admin.<resource>.view` | List and detail | | `admin.<resource>.export` | CSV export (with `view`) | | `admin.<resource>.add` / `.change` / `.delete` / `.restore` | Create / edit / delete / restore | | `admin.<resource>.action.<id>` / `.bulk.<id>` | Running a row / bulk action | | `admin.<resource>.*`, `admin.*` | Everything on one resource / all resources | | `admin.media.view` / `.add` / `.delete` (or `admin.media.*`) | Media library | | `admin.audit.view` | Audit trail (it shows every resource's data) | | an action's `permission` | Running that action | | a widget's or page's `permission` | Seeing it | Resources a user can't view are hidden from the sidebar. Fields marked `sensitive` are visible only to superusers. ```ts app.admin({ resources: [Order], permissions: { requireSuperuser: true, // only superusers may enter authorizationManager: policies, // your object-level policies (e.g. own tenant only) }, }); ``` --- ## 9. Audit trail, health checks and branding ```ts import { createApp } from 'jsango'; import type { IAuditStore } from 'jsango'; declare const auditStore: IAuditStore; // e.g. a table-backed implementation declare const redis: { ping(): Promise<string> }; const app = createApp(); app.admin({ resources: [], auditStore, // default: in memory healthChecks: { redis: () => redis.ping() }, // the database is checked automatically title: 'Acme Admin', brandSubtitle: 'Operations', // '' hides it logoUrl: '/static/acme-logo.svg', // https URL, path served by your app, or data:image URL logoText: 'AC', // badge letters when there is no logoUrl (default: the title's initials) faviconUrl: '/static/favicon.png', // default: logoUrl siteUrl: 'https://acme.example.com', defaultTheme: 'system', customCss: ':root { --chakra-colors-brand-solid: #7c3aed; }', path: '/backoffice', }); ``` - **Audit Trail** records who changed what (with before/after values), exports, bulk actions, sign-ins and sign-outs, with IP and user agent. It is filterable by resource and action. - **Branding** works on every screen size: long titles are shortened with "…" in the header, the subtitle is hidden on phones, and the full title stays on the dashboard and the login page. Square logos look best; only http(s), relative and `data:image` URLs are accepted. - **System** shows process stats and every health check, with latency or the error message. --- ## 10. Security | Threat | Protection | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Default or guessable credentials | Login refused in production without a configured password; use `createAuth` users | | Password guessing | 5 failures per account per 15 minutes (20 per IP) → `429` (auth-kit lockout when using `createAuth`) | | Stolen session token | 256-bit random tokens, stored hashed, 8-hour lifetime; sessions can be revoked | | Mass assignment | Only `createFields` / `editFields` are written | | Privilege escalation by staff | Limited staff get only the permissions you grant (default deny) | | Clickjacking / injected scripts | The admin page sends `frame-ancestors 'none'`, `X-Frame-Options: DENY` and a CSP allowing only its own scripts | | Stale access after a ban | App users' roles and `isActive` are re-checked every minute; idle sessions end after an hour | | Malicious uploads | The file type comes from the extension, not the browser; S3 serves non-media files as downloads and stays inside its `root` folder | | Data leaks | Hidden and sensitive fields are stripped server-side; sorting, filtering and export only on visible fields | | SQL injection via sort/filter | Sort and filter fields are allow-listed; values are bound parameters | | CSV / formula injection | Exported cells starting with `= + - @` are prefixed with `'` | | Export links leaking | One-time links valid for 60 seconds; the session token never appears in a URL | | 2FA secret leaks | The QR secret never leaves the page (no third-party QR service) | --- ## 11. HTTP API All routes are under `/admin/api/v1` (`apiPrefix`) and return `{ ok, data }` or `{ ok: false, error }`. | Route | Purpose | | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- | | `POST /auth/login` · `POST /auth/logout` · `GET /auth/me` | Sign in / out | | `GET /auth/sessions` · `DELETE /auth/sessions/:id` · `POST /auth/sessions/terminate-others` | Sessions | | `GET /resources` | Resources the user can view, with their schemas | | `GET /resources/:id?page&pageSize&search&sort&sortDirection&filter_<field>` | List | | `POST /resources/:id` · `GET/PATCH/DELETE /resources/:id/:pk` | CRUD | | `POST /resources/:id/bulk/delete` · `POST /resources/:id/bulk/:action` · `POST /resources/:id/:pk/actions/:action` | Actions | | `POST /resources/:id/export` → `GET /resources/:id/export?ticket=` | CSV export | | `GET /dashboard` · `GET /dashboard/widgets/:id` | Dashboard | | `GET /pages` · `GET /pages/:id` · `GET /pages/:id/widgets/:widget` | Custom pages | | `GET /audit` · `GET /system/health` | Audit trail, health | | `GET /media` · `GET /media/:disk?prefix=` | Media disks and their files | | `POST /media/:disk` (raw body, `x-file-name` header) · `DELETE /media/:disk?key=` | Upload / delete a file | --- <!-- source: docs/admin/ADMIN-RESOURCE-GUIDE.md (Admin resources (manual setup)) --> # JSango Admin Resource & Code Generation Guide This guide explains how to define, scaffold, and customize Admin Resources in the JSango framework using `@jsango/admin-core`, `@jsango/admin-ui`, and `@jsango/cli`. --- ## 1. Quick Start with CLI Scaffolding The fastest way to add a new model and its Admin interface is using the JSango CLI: ```bash npx jsango make:admin <ModelName> [options] ``` ### Examples: ```bash # Generate 'Article' resource + ORM model npx jsango make:admin Article # Generate with custom group and icon npx jsango make:admin Coupon --group "Promotions & Discounts" --icon "tag" # Generate with custom output directory npx jsango make:admin ProductReview --group "Catalog" --output "src/admin" ``` ### CLI Options: | Flag | Short | Default | Description | | -------------- | ----- | ---------------------- | -------------------------------------------------- | | `--group` | `-g` | `'Content Management'` | Category group in the admin sidebar | | `--icon` | `-i` | `'file-text'` | [Lucide Icon](https://lucide.dev/icons) name | | `--output` | `-o` | `'src/admin'` | Target folder for resource file | | `--with-model` | `-m` | `true` | Generate ORM model in `src/models` if not existing | --- ## 2. Defining an Admin Resource Manually An `AdminResource` configures how an ORM model is presented, filtered, searched, and mutated in the Admin UI. ```typescript // src/admin/page-resource.ts import { AdminResource } from '@jsango/admin-core'; export const PageResource = new AdminResource({ id: 'pages', modelName: 'Page', label: 'Site Page', pluralLabel: 'Site Pages', navigationGroup: 'Content Management', navigationIcon: 'file-text', navigationOrder: 1, primaryKey: 'id', fields: [ { name: 'id', type: 'uuid', readonly: true }, { name: 'title', type: 'text', required: true, label: 'Page Title', searchable: true }, { name: 'slug', type: 'text', required: true, label: 'URL Slug' }, { name: 'description', type: 'textarea', label: 'Meta Description', searchable: true }, { name: 'content', type: 'textarea', required: true, label: 'Page Content' }, { name: 'status', type: 'enum', enumChoices: [ { label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }, { label: 'Archived', value: 'archived' }, ], }, { name: 'isActive', type: 'boolean', label: 'Visible to Public' }, { name: 'createdAt', type: 'datetime', readonly: true, label: 'Created At' }, ], listFields: ['id', 'title', 'slug', 'status', 'isActive', 'createdAt'], searchFields: ['title', 'slug', 'description'], filters: [ { name: 'status', type: 'enum', field: 'status' }, { name: 'isActive', type: 'boolean', field: 'isActive' }, ], bulkActions: [ { id: 'bulk-publish', label: 'Publish Selected Pages', requiresConfirmation: true, }, { id: 'bulk-archive', label: 'Archive Selected Pages', requiresConfirmation: true, }, ], defaultSortField: 'createdAt', defaultSortDirection: 'desc', }); ``` --- ## 3. Supported Field Types | Field Type | Form Widget | Supported Options | | ------------ | ------------------------- | ---------------------------------------------------------- | | `'text'` | Single-line Text Input | `required`, `searchable`, `label` | | `'textarea'` | Multi-line Textarea | `required`, `label`, `description` | | `'number'` | Numeric Input (int/float) | `required`, `sortable`, `filterable` | | `'email'` | Email Input | Validates email syntax | | `'boolean'` | Checkbox / Switch | Rendered with Yes/No badges in changelist | | `'enum'` | Select Dropdown | `enumChoices: [{ label, value }]` | | `'datetime'` | Datetime Display / Picker | Formats timestamps gracefully | | `'uuid'` | Readonly identifier | Automatically generated | | `'relation'` | Searchable dropdown | `relationTarget` (model name), side-panel "+ New" / "Edit" | | `'image'` | Upload + media library | Stores the uploaded file's URL | | `'file'` | Upload + media library | Stores the uploaded file's URL | Other types: `'date'`, `'time'`, `'url'`, `'json'`, `'password'`, `'computed'` (with `computedGetter`) and `'readonly'`. Every field also accepts `readonly`, `hidden`, `sensitive`, `sortable`, `searchable`, `filterable` and `widget`. --- ## 4. Registering in the Admin Registry & Query Adapter The simplest path is `app.admin({ resources: [PageResource] })` from `jsango`, which builds the registry, an ORM-backed query adapter (column selection, keyset-streamed CSV export, one-query bulk delete) and the Admin API for you. See the [admin panel guide](./ADMIN-CUSTOMIZATION.md). For a manual setup, register resources on an `AdminRegistry` and implement `IAdminQueryAdapter` to bridge the ORM with the Admin CRUD layer: ```typescript // src/admin/index.ts import { AdminRegistry } from '@jsango/admin-core'; import { AdminServer, type IAdminQueryAdapter } from '@jsango/admin-server'; import { AdminPermissionChecker } from '@jsango/admin-auth'; import { AdminAuditLogger, InMemoryAuditStore } from '@jsango/admin-audit'; import { AdminMediaManager, LocalDiskMediaStorage } from '@jsango/admin-media'; import { defaultModelRegistry } from '@jsango/orm'; import { Router } from '@jsango/router'; import { PageResource } from './pages-resource.js'; // ... other resources export function createAdminRegistry(): AdminRegistry { const registry = new AdminRegistry(); registry.register(PageResource); // register other resources... return registry; } function modelFor(name: string) { const modelClass = defaultModelRegistry.getModel(name); if (!modelClass) throw new Error(`Model ${name} is not registered`); return modelClass; } export function createOrmAdminQueryAdapter(): IAdminQueryAdapter { return { // `query` arrives validated: sort/filter fields are allow-listed and pageSize is capped. async list({ modelName, query, searchFields, primaryKey, defaultSortField, defaultSortDirection, pageSize, }) { let q = modelFor(modelName).query(); const search = query.search; if (search && searchFields.length > 0) { // Group the ORs so they can't escape the filters below. q = q.where((g) => searchFields.reduce((acc, f) => acc.orWhereLike(f, `%${search}%`), g)); } for (const [field, value] of Object.entries(query.filters ?? {})) q = q.where(field, value); const dir = (query.sortDirection ?? defaultSortDirection) === 'desc' ? 'DESC' : 'ASC'; q = q.orderBy(query.sort ?? defaultSortField, dir).orderBy(primaryKey, 'ASC'); const result = await q.paginate({ page: query.page ?? 1, pageSize: query.pageSize ?? pageSize, }); return { ...result, items: result.items.map((item) => item.toJSON()) }; }, async findById({ modelName, id }) { const item = await modelFor(modelName).find(id); return item ? item.toJSON() : null; }, async create({ modelName, data }) { return (await modelFor(modelName).create(data)).toJSON(); }, async update({ modelName, id, data }) { const item = await modelFor(modelName).findOrFail(id); for (const [key, value] of Object.entries(data)) item.set(key, value); return (await item.save()).toJSON(); }, async delete({ modelName, id, soft }) { const item = await modelFor(modelName).find(id); if (item) await item.delete({ force: !soft }); }, }; } // Mount the Admin REST API const router = new Router(); new AdminServer({ registry: createAdminRegistry(), queryAdapter: createOrmAdminQueryAdapter(), permissions: new AdminPermissionChecker(), audit: new AdminAuditLogger({ store: new InMemoryAuditStore() }), prefix: '/admin/api/v1', // Optional media library disks (Admin → Media, and uploads of `image` / `file` fields) media: { local: new AdminMediaManager({ storage: new LocalDiskMediaStorage() }) }, }).mount(router); ``` With `app.admin()` this is all automatic: models' `belongsTo` foreign keys become `relation` fields, and `media` defaults to a local disk served at `/media`. `AdminServer` doesn't serve files: serve the `LocalDiskMediaStorage` folder yourself (or use `S3MediaStorage` with a public URL). See the [admin guide](./ADMIN-CUSTOMIZATION.md#relations) for relations and the media library. --- ## 5. Mounting the Chakra UI Admin Console Mount the single-page application directly on your HTTP router: ```typescript import { Router } from '@jsango/router'; import { createAdminUiHandler } from '@jsango/admin-ui'; const router = new Router(); // Mounts Chakra UI v3 Admin Console with full mobile & desktop responsiveness router.get( '/admin', createAdminUiHandler({ title: 'JSango Enterprise Admin', brandSubtitle: 'Management Console', apiBasePath: '/admin/api/v1', // where AdminServer is mounted defaultTheme: 'dark', }) ); ``` --- <!-- source: docs/architecture/queue/README.md (Background jobs) --> # @jsango/queue Architecture Overview ## 1. Core Mission & Philosophy `@jsango/queue` provides a production-grade, driver-agnostic background job processing system for the JSango (`jsango`) framework. It enables deferred execution, retry policies, and concurrent worker processing — all without coupling application logic to a specific queue backend. Key design principles: - **Driver Abstraction**: All queue backends implement a single `IQueueDriver` contract. - **Named Queues**: Multiple named queues can coexist (`default`, `emails`, `reports`), each with independent configurations. - **Job Registry**: Strongly-typed job definitions registered centrally and resolved at execution time. - **AT-LEAST-ONCE Delivery**: Jobs are guaranteed to execute at least once; handlers must be idempotent. - **Visibility Leases**: Workers claim jobs with time-bounded leases to prevent duplicate processing in multi-worker environments. The database driver claims atomically (a conditional `UPDATE`), and a job is stopped at its lease (`min(timeoutMs, leaseTimeoutMs)`) so it can never be picked up by a second worker while still running: give long jobs a longer `leaseTimeoutMs`. - **Graceful Shutdown**: Workers honor `AbortSignal` for clean termination of in-flight work. - **Zero Mandatory External Dependencies**: Ships with `MemoryQueueDriver` and `DatabaseQueueDriver` (using the existing `@jsango/database` layer). --- ## 2. Architecture Layers ``` Application Code ↓ QueueManager (orchestrator) ↓ Queue (named queue handle, dispatch/delay/schedule) ↓ IQueueDriver (raw queue backend) ├── MemoryQueueDriver (in-memory, development/testing) └── DatabaseQueueDriver (persistent, via @jsango/database) ↓ Worker (polling, claim, execute, retry/fail) ↓ JobRegistry (type → handler resolution) ↓ MiddlewarePipeline (pre/post job processing hooks) ↓ FailedJobStore (dead-letter storage for exhausted retries) ``` --- ## 3. Key Components ### 3.1 QueueManager - Central orchestrator managing named queue connections, workers, and lifecycle. - Resolves driver instances via registered `QueueDriverFactory` functions. - Provides convenience methods: `dispatch()`, `delay()`, `schedule()`. - Creates and manages `Worker` instances with merged configuration defaults. - Graceful `close()` shutting down all workers and driver connections in parallel. ### 3.2 Queue - Named queue handle wrapping an `IQueueDriver` for a specific queue name. - Constructs immutable `Job` objects with generated IDs, timestamps, and retry policies. - Provides `dispatch<Payload>()`, `delay<Payload>()`, `schedule<Payload>()`, `depth()`, `stats()`, and `clear()`. ### 3.3 IQueueDriver Low-level driver contract implemented by all queue backends: - `enqueue<Payload>`: Adds a job to the queue. - `claim<Payload>`: Claims pending jobs with visibility lease for exclusive processing. - `acknowledge`: Marks a job as successfully completed. - `release`: Returns a job to the queue for retry with optional delay. - `fail`: Permanently marks a job as failed. - `cancel`: Cancels a pending/scheduled job. - `getJob<Payload>`, `getQueueDepth`, `getStats`, `clear`, `close`. - Exposes `QueueCapabilities` declaring supported features (priority, delayed jobs, visibility leases). ### 3.4 Worker - Long-running polling process that claims and executes jobs. - Configurable concurrency, polling interval, idle backoff (linear), and lease timeout. - Job timeout enforcement via `AbortSignal` with `JobTimeoutError`. - Retry orchestration using `RetryCalculator` (fixed or exponential backoff with optional jitter). - Failed job routing to `IFailedJobStore` after retry exhaustion. - Graceful shutdown with configurable `shutdownTimeoutMs`. - Supports `--once` mode for single-pass execution (CI/testing). ### 3.5 JobRegistry - Type-safe mapping of job type strings to `JobDefinition` objects. - Prevents duplicate registrations. - Resolves handlers, default retry policies, and timeout values at dispatch time. ### 3.6 RetryCalculator - Computes retry delays based on `RetryPolicy`: - `fixed`: Constant delay between retries. - `exponential`: Doubling delay with configurable `maxDelayMs` cap. - Optional `jitter`: Adds ±25% randomization to prevent retry storms. ### 3.7 MiddlewarePipeline (Queue) - Separate from HTTP middleware — follows the same onion pattern but scoped to job execution. - Each middleware wraps job handlers with pre/post processing hooks. - Used for logging, metrics, tracing, and error enrichment around job execution. ### 3.8 FailedJobStore - Dead-letter storage for jobs that exhaust all retry attempts. - `IFailedJobStore` interface with `record()`, `list()`, `get()`, `remove()`, `clear()`, `count()`. - Built-in `MemoryFailedJobStore` for development; database-backed implementation follows the same contract. --- ## 4. Driver Implementations ### 4.1 MemoryQueueDriver - In-process `Map`-based queue storage. - Supports all `QueueCapabilities`: priority sorting, delayed jobs, visibility leases. - Deterministic behavior ideal for testing and local development. ### 4.2 DatabaseQueueDriver - Persistent queue storage using `@jsango/database` connection pooling and queries. - Uses `locked_until` column for visibility lease enforcement in multi-worker environments. - Claims jobs with `UPDATE ... WHERE locked_until < NOW()` pattern for distributed locking. - Suitable for production workloads without external dependencies (Redis/RabbitMQ). --- ## 5. Job Lifecycle ``` dispatch() → [pending] ↓ Worker.claim() → [processing] ↓ Job Handler executes ╱ ╲ Success Failure ↓ ↓ acknowledge() attempts < maxAttempts? → [completed] ╱ ╲ Yes No ↓ ↓ release() fail() → [pending] → [failed] (with delay) → FailedJobStore ``` --- ## 6. CLI Integration | Command | Description | | :-------------------------------- | :---------------------------------------------------- | | `queue:work [--once] [--queue]` | Starts a worker process; `--once` for single-pass. | | `queue:status [--queue] [--json]` | Reports queue metrics (pending, processing, failed). | | `queue:failed [--json]` | Lists failed jobs from the dead-letter store. | | `queue:retry [--id] [--all]` | Retries specific or all failed jobs. | | `queue:clear [--queue] [--force]` | Clears all jobs from a queue with confirmation guard. | --- ## 7. Testing - **Contract Tests** (`contract.ts`): Reusable test suite validating any `IQueueDriver` implementation. - **Unit Tests**: Worker lifecycle, retry calculation, middleware pipeline, registry, shutdown. - **Integration Tests**: Database driver with visibility leases and multi-worker scenarios. - **Fake Driver** (`FakeQueueDriver`): Testing utility for application-level test isolation. --- <!-- source: docs/architecture/events/README.md (Events) --> # @jsango/events Architecture Overview ## 1. Core Mission & Philosophy `@jsango/events` provides a production-grade, typed in-process and background event dispatching system for the JSango (`jsango`) framework. It enables loosely-coupled component communication, telemetry integration, domain event propagation, and background job decoupling. Key design principles: - **Strict Type Safety**: Events carry typed payloads and explicit schema versions. - **Execution Modes**: Handlers declare their execution semantics (`sync`, `async`, `queued`). - **Priority-Based Dispatch**: Handlers are executed deterministically sorted by priority. - **Middleware Pipeline**: Dedicated event middleware (onion pattern) separate from HTTP and Queue middleware. - **Zero Hard Queue Dependency**: Integrates with `@jsango/queue` through the `IEventQueueAdapter` contract without compile-time coupling. - **Admin & Telemetry Introspection**: `EventRegistry.inspect()` provides structured data for diagnostics and Admin UI. --- ## 2. Architecture Layers ``` Application / Domain Logic ↓ EventBus ├── EventMiddlewarePipeline (validation, logging, telemetry) ├── EventRegistry (priority-sorted type → handler mapping) │ ├── Sync Handlers (sequential, deterministic order) │ ├── Async Handlers (concurrent, Promise.allSettled) │ └── Queued Handlers │ ↓ │ IEventQueueAdapter │ ↓ │ @jsango/queue └── EventLifecycleHooks (onDispatched, onHandlerStarted, onHandlerCompleted, onHandlerFailed) ``` --- ## 3. Key Components ### 3.1 EventDefinition & createEvent - Immutable representation of an event with unique `eventId` (UUIDv4), `type`, `payload`, `timestamp`, `schemaVersion`, and optional `metadata`. - Created via the `createEvent()` factory helper. ### 3.2 EventRegistry - Manages handler registrations grouped by event type. - Enforces duplicate prevention for explicit handler IDs. - Deterministically sorts handlers by priority descending (`priority: number`). - Filters handlers by mode (`getHandlersByMode(type, mode)`). ### 3.3 EventBus - Orchestrates event dispatching through: 1. Middleware pipeline execution. 2. Sequential execution of `sync` handlers in priority order. 3. Concurrent execution of `async` handlers via `Promise.allSettled`. 4. Forwarding `queued` handlers to `IEventQueueAdapter`. - Provides `on()`, `emit()`, `dispatch()`, `off()`, `use()`, `setQueueAdapter()`, `close()`. ### 3.4 EventSerializer - Validates and serializes event definitions into JSON strings suitable for transport across queues or websockets. - Rejects non-serializable objects (functions, symbols, circular references). ### 3.5 QueueEventAdapter - Implements `IEventQueueAdapter` to serialize events into background job payloads (`event:<type>`) dispatched via `@jsango/queue`. --- ## 4. Execution Semantics | Mode | Execution Timing | Error Handling | Use Case | | :------- | :--------------------------- | :------------------------------------------------- | :----------------------------------------- | | `sync` | Sequential in priority order | Logged; aggregate throw with `throwOnHandlerError` | Transactional side-effects, cache eviction | | `async` | Concurrent in-process | Logged; isolated via Promise.allSettled | Non-blocking metrics, local notifications | | `queued` | Deferred via queue adapter | Handled by queue worker retry policies | Emails, webhooks, heavy background jobs | --- ## 5. Testing Utilities - `FakeEventBus` records all dispatched and emitted events for assertion in unit/integration tests without executing real handlers or background queues. --- <!-- source: docs/architecture/cache/README.md (Cache) --> # @jsango/cache Architecture Overview ## 1. Core Mission & Philosophy `@jsango/cache` provides a production-grade, driver-agnostic caching abstraction for the JSango (`jsango`) framework. It is designed around the principle that caching is infrastructure, not domain logic — the application layer interacts with a high-level `CacheStore` interface, while backend storage details are encapsulated behind pluggable drivers. Key design principles: - **Driver Abstraction**: All storage backends implement a single `ICacheDriver` contract. - **Named Stores**: Multiple cache stores can coexist (`default`, `sessions`, `api-responses`), each with independent drivers and configuration. - **Stampede Protection**: `remember()` / `getOrSet()` operations use Promise-based locking to prevent thundering herd on cache misses. - **Namespace Isolation**: Logical cache partitioning via `namespace()` without driver-level support requirements. - **Fallback Resilience**: Configurable behavior (`fail-fast`, `fallback-to-memory`, `bypass`) when the primary store is unavailable. - **Zero Mandatory External Dependencies**: Ships with `MemoryCacheDriver` for development and testing; Redis adapter follows the same driver contract. --- ## 2. Architecture Layers ``` Application Code ↓ CacheManager (orchestrator) ↓ CacheStore (high-level operations, stampede protection, namespacing, stats) ↓ CacheKeyBuilder (key normalization, prefixing, validation) ↓ SafeCacheSerializer (JSON serialization with Date/BigInt support) ↓ ICacheDriver (raw storage backend) ├── MemoryCacheDriver (built-in, LRU-capable) └── RedisAdapter (external adapter) ``` --- ## 3. Key Components ### 3.1 CacheManager - Central orchestrator managing multiple named cache stores. - Resolves driver instances via registered `CacheDriverFactory` functions. - Provides high-level convenience methods that delegate to the default store. - Implements fallback modes for distributed cache failures. - Manages lifecycle shutdown (`close()`) for all initialized stores. ### 3.2 CacheStore - High-level cache interface exposed to application code. - Wraps `ICacheDriver` with: - `CacheKeyBuilder` for safe key normalization and namespacing. - `SafeCacheSerializer` for transparent value serialization. - Stampede protection via `remember()` using a concurrent `Promise` lock map. - Hit/miss/set/delete/error statistics tracking. - Supports `namespace()` for logical cache partitioning. ### 3.3 ICacheDriver Low-level driver contract implemented by all storage backends: - `get<T>`, `set<T>`, `has`, `delete`, `clear(prefix?)` (a store or namespace only clears its own keys; the Redis driver uses `SCAN` + `DEL` and refuses to clear without a prefix, so it never runs `FLUSHDB`) - `increment`, `decrement` - `expire`, `ttl` - `getMany<T>`, `setMany<T>`, `deleteMany` (batch operations) - `close()` (lifecycle cleanup) - Exposes `CacheCapabilities` declaring supported features. ### 3.4 CacheKeyBuilder - Normalizes and validates cache keys against ASCII control characters. - Constructs namespaced, prefixed keys: `{application}:{environment}:{prefix}:{namespace}:{key}`. - Enforces maximum key length constraints. ### 3.5 SafeCacheSerializer - JSON-based serialization with custom replacer/reviver functions. - Handles non-standard JSON types: `Date` (ISO 8601 round-trip), `BigInt` (string representation). - Rejects non-serializable values (functions, symbols) with descriptive errors. --- ## 4. Driver Implementations ### 4.1 MemoryCacheDriver - In-process `Map`-based storage with passive TTL expiration on access. - Configurable `maxEntries` for LRU-style bounded caching. - Full `CacheCapabilities` support: TTL, increment/decrement, multi-key, batch operations. - Deterministic behavior ideal for testing and local development. ### 4.2 RedisAdapter - Adapter interface for connecting to external Redis instances. - Follows identical `ICacheDriver` contract for seamless swapping. - Redis-specific features (pub/sub, Lua scripts) are not exposed through the generic driver contract. --- ## 5. Fallback Modes | Mode | Behavior | | :-------------------- | :------------------------------------------------------------------- | | `fail-fast` (default) | Primary store error propagates immediately to the caller. | | `fallback-to-memory` | Transparently falls back to an in-memory store on primary failure. | | `bypass` | Returns default/empty values without caching; factory still invoked. | --- ## 6. CLI Integration - `cache:clear [--store <name>] [--force]`: Clears a named cache store with destructive confirmation guard. --- ## 7. Testing - **Contract Tests** (`contract.ts`): Reusable test suite validating any `ICacheDriver` implementation against the full contract. - **Unit Tests**: Serializer, key builder, store, manager, stampede protection, and Redis adapter. - **Fake Store** (`FakeCacheDriver`): Testing utility providing a minimal in-memory driver for application-level test isolation. --- <!-- source: docs/architecture/websocket/README.md (WebSockets) --> # @jsango/websocket Architecture Overview > **Building an app?** Use `app.ws()` from `jsango` (see the README): it shares your HTTP routes' > middleware and auth, rooms, `app.to(room)` server push and secure defaults. This package is the > lower-level engine for custom servers. ## 1. Core Mission & Philosophy `@jsango/websocket` delivers a production-grade real-time communication subsystem for the JSango (`jsango`) framework. It provides runtime-independent WebSocket abstractions, room management, broadcasting, connection limits, heartbeat monitoring, backpressure enforcement, and HTTP upgrade authentication. Key design principles: - **Engine Isolation**: Underlying WebSocket engines (such as the Node.js `ws` library) are completely isolated behind the `IWebSocketServer` and `IWebSocketConnection` interfaces. - **Room Management**: Dynamic pub/sub rooms with multi-room membership, join authorization, and auto-cleanup upon disconnection. - **Connection Limits**: Built-in defensive limits for total connections, per-identity connections, message size, and room count per client. - **Backpressure & Heartbeat**: Monitored socket buffer sizes (`bufferedAmount`) to prevent out-of-memory errors, alongside ping/pong heartbeat health checks. - **Auth Reusability**: Seamlessly interoperates with `@jsango/auth` identities without duplicate identity models. - **Event Bridges**: Bi-directional adapters connecting the WebSocket server with the `@jsango/events` EventBus. --- ## 2. Architecture Layers ``` Client WebSocket Connection ↓ HTTP Server Upgrade Request (NodeHttpServer) ↓ Authentication Hook (Token / Headers / Query) ↓ NodeWebSocketAdapter (ws wrapper) ↓ WebSocketManager ├── HeartbeatManager (ping/pong health checks) ├── RoomManager (room membership & lookups) ├── LocalTransport (IRealtimeTransport abstraction) ├── WebSocketMiddlewarePipeline (message interceptors) └── Type-specific WebSocketHandlers ``` --- ## 3. Key Components ### 3.1 IWebSocketConnection & WebSocketConnection - Represents an active client connection. - Tracks connection state (`connecting`, `connected`, `closing`, `closed`), identity, joined rooms, metadata, and buffered amount. - `send(message)` sends typed JSON frames: `{ type: string, payload?: unknown, requestId?: string }`. - `close(code, reason)` and `terminate()`. ### 3.2 WebSocketContext - Execution context passed to message handlers and middleware. - Matches the ergonomics of HTTP `RequestContext` (provides `connectionId`, `connection`, `identity`, `state: Map<string, unknown>`, `reply()`, `send()`). ### 3.3 RoomManager - High-performance bidirectional mapping between connections and rooms. - Supports `join()`, `leave()`, `leaveAll()`, `getMembers()`, `getRooms()`, and connection limits. - Automatically cleans up empty rooms. ### 3.4 WebSocketManager - Central coordinator managing connection lifecycles, routing inbound messages, executing middleware, broadcasting to rooms, and tracking live metrics (`WebSocketStats`). ### 3.5 NodeWebSocketAdapter - Implements `IWebSocketServer` by adapting Node's HTTP upgrade mechanism and `ws.WebSocketServer`. - Handles upgrade requests, checks permissions/limits, and binds connection listeners. ### 3.6 Event & WebSocket Bridges - `WebSocketEventBridge`: Forwards events emitted on the `EventBus` directly to WebSocket rooms or all connected clients. - `WebSocketToEventBridge`: Routes incoming WebSocket client messages into the `EventBus` for server-wide processing. --- ## 4. Connection Limits & Security | Configuration | Description | Default | | :-------------------------- | :--------------------------------------------------------- | :-------- | | `maxTotalConnections` | Maximum concurrent connections on this server instance | Unlimited | | `maxConnectionsPerIdentity` | Maximum concurrent connections per authenticated user ID | Unlimited | | `maxRoomsPerConnection` | Maximum rooms a single connection may join | Unlimited | | `maxMessageSizeBytes` | Maximum allowed inbound JSON payload size in bytes | Unlimited | | `maxBufferedAmountBytes` | Maximum send buffer before backpressure error is triggered | Unlimited | | `allowAnonymous` | Permit unauthenticated connections | `false` | --- ## 5. Testing Utilities - `FakeWebSocketConnection`: In-memory connection recording sent messages for unit testing. - `FakeWebSocketServer`: Lightweight server harness for testing controllers and bridges. --- <!-- source: docs/architecture/openapi/README.md (OpenAPI) --> # OpenAPI Architecture & Generation Pipeline (`@jsango/openapi`) ## Overview `@jsango/openapi` provides deterministic, zero-reflection, metadata-driven OpenAPI 3.1.0 document generation for the JSango framework. Rather than parsing ASTs, scanning controllers, or executing heavy reflection, the OpenAPI generator derives API specifications directly from: 1. **Router Metadata**: Routes, methods, paths, operation IDs, summaries, tags, parameters, and responses. 2. **Validation Schemas**: Field descriptors, validation rules, types, formats, constraints, and defaults. 3. **ORM Model Metadata**: Model properties, types, nullability, and primary keys. 4. **Admin Resource Metadata**: Dedicated Admin API routes and tags with strict optional opt-in. 5. **Authentication & Authorization Metadata**: Bearer token, cookie/session, API key schemes, and security scopes. --- ## Core Principles - **Deterministic Generation**: Generated documents are strictly sorted by paths, operations, tags, and schema component names to guarantee reproducible output across runs and builds. - **Zero-Dependency Core**: YAML formatting and JSON formatting are provided natively without requiring heavy external dependencies in the production runtime. - **Explicit Admin Isolation**: Admin endpoints are excluded by default (`includeAdmin: false`) to avoid leaking sensitive administrative APIs to public API consumers. - **Conflict Detection**: Throws structured errors (`DuplicateOperationIdError`, `ConflictingSchemaError`) when operation IDs or incompatible schema definitions collide. - **Spec Validation**: Built-in validation verifies required top-level fields, path structure, operations, and component schema references. --- ## Architecture Components ```mermaid graph TD A[Router Routes] --> G[OpenApiGenerator] B[Validation Schemas] --> VA[ValidationAdapter] --> G C[ORM Models] --> OA[OrmAdapter] --> G D[Admin Resources] --> AA[AdminAdapter] --> G E[Auth Metadata] --> G R[OpenApiRegistry] --> G G --> D3[OpenApiDocument 3.1.0] D3 --> V[OpenApiValidator] D3 --> F[OpenApiFormatter - JSON / YAML] D3 --> EP[createOpenApiHandler - /openapi.json] ``` --- ## `app.openapi()` options `app.openapi({ path, docsPath, title, middleware })` serves the spec (`/openapi.json`) and Swagger UI (`/docs`). Both are public unless you pass `middleware`, e.g. `[auth.required({ roles: ['admin'] })]` for an internal API. Admin routes (wherever the admin is mounted) and the docs routes themselves are never listed. Swagger UI is loaded from a pinned version of `swagger-ui-dist`. ## Key APIs ### `OpenApiGenerator` ```typescript import { Router } from '@jsango/router'; import { OpenApiGenerator } from '@jsango/openapi'; const router = new Router(); const generator = new OpenApiGenerator({ info: { title: 'Acme API', version: '1.0.0', description: 'Production API spec', }, openapi: '3.1.0', includeAdmin: false, }); const doc = generator.generate(router); ``` ### `createOpenApiHandler` Serves the specification over HTTP. `format` is `'json'` (default), `'yaml'` or `'auto'` (negotiated from the `Accept` header); `?format=yaml` always returns YAML. Protect the route with your own middleware if the spec must not be public: ```typescript import { Router } from '@jsango/router'; import { OpenApiGenerator, createOpenApiHandler } from '@jsango/openapi'; const router = new Router(); const generator = new OpenApiGenerator({ info: { title: 'Acme API', version: '1.0.0' } }); router.get( '/openapi.json', createOpenApiHandler(generator, { router, format: 'json', cacheDocument: true, }) ); ``` ### CLI Commands - `jsango openapi:generate [--output <path>] [--format json|yaml] [--title <title>] [--version <version>] [--include-admin]` - `jsango openapi:validate [--file <path>] [--include-admin]` --- <!-- source: docs/ai/tools.md (AI tools) --> # AI Tools in JSango JSango provides a unified, type-safe tool system integrated with `@jsango/validation`, permission authorization, timeouts, and human-in-the-loop approval. --- ## Defining a Tool Define tools using the `tool()` builder: ```typescript import { tool } from 'jsango'; import { object, string, number } from '@jsango/validation'; export const getOrderTool = tool({ name: 'getOrder', description: 'Retrieve order details by order ID', schema: object({ orderId: string(), }), execute: async ({ orderId }, context) => { // context contains user, tenant, and request metadata const order = await Order.find(orderId); if (!order) { throw new Error(`Order ${orderId} not found`); } return order; }, }); ``` --- ## Function-Style Shorthand For simple tools, pass the name, description and function. Parameters are inferred from the function's destructured argument: ```typescript import { tool } from 'jsango'; export const getWeather = tool( 'getWeather', 'Get current weather for a city', async ({ city }: { city: string }) => { return { city, temperature: 22, condition: 'Sunny' }; } ); ``` --- ## Tool Security & Authorization `permissions` are checked against `context.user` of the run: users with `hasPermission()` (jsango auth identities) or a `permissions: string[]` array. Runs without a user, or users lacking a permission, get a `ToolError` instead of executing the tool. ```typescript import { tool, object, string } from 'jsango'; export const deleteUserTool = tool({ name: 'deleteUser', description: 'Delete a user account', schema: object({ userId: string() }), permissions: ['users.delete'], timeoutMs: 5000, // default 30s execute: async ({ userId }: { userId: string }) => { await User.query().where('id', userId).delete(); return { success: true }; }, }); await adminAgent.run({ input: 'Delete user 42', context: { user: { id: 'admin_1', permissions: ['users.delete'] } }, }); ``` --- ## Argument Validation Tool arguments come from the model, so a prompt injection can put anything in them. Before `execute` runs, jsango checks them: with a `schema` (`schema({ ... })`) the arguments are validated and coerced and unknown keys are dropped; with a plain JSON schema, required keys must be present and undeclared keys are dropped. Invalid arguments become a tool error the model sees, never a call. ## Human Approval for Sensitive Actions Tools with `requiresApproval: true` are never executed by the agent itself; the run pauses instead (see [human-in-the-loop](./human-in-the-loop.md)). ```typescript import { tool, object, string, number } from 'jsango'; export const refundPaymentTool = tool({ name: 'refundPayment', description: 'Issue a monetary refund to a customer', schema: object({ chargeId: string(), amount: number() }), requiresApproval: true, execute: async ({ chargeId, amount }: { chargeId: string; amount: number }) => { return await payments.refund(chargeId, amount); }, }); ``` --- <!-- source: docs/ai/memory.md (AI memory) --> # AI Memory in JSango An agent with `memory` remembers the conversation between `run()` calls. History is stored per **tenant + user + conversation**, so two users never see each other's messages, even if they use the same `conversationId`. --- ## In-Memory Store For tests, scripts and single-process apps: ```typescript import { agent, InMemoryMemoryStore } from 'jsango'; const assistant = agent({ name: 'ChatAssistant', instructions: 'Help users with their questions.', memory: new InMemoryMemoryStore(50), // keeps the last 50 messages per conversation }); await assistant.run({ input: 'My name is Alice.', context: { conversationId: 'session_123', user: { id: 'user_456' }, tenantId: 'acme' }, }); const res = await assistant.run({ input: 'What is my name?', context: { conversationId: 'session_123', user: { id: 'user_456' }, tenantId: 'acme' }, }); // res.text => "Your name is Alice." ``` `memory: true` is shorthand for a new `InMemoryMemoryStore`. Without a `conversationId`, each user (and tenant) gets a single `default` conversation. --- ## Token Budget: What Gets Sent Back Memory is built to send the model as few tokens as possible: - **Tool calls and results are kept only for the latest turn.** Older turns are sent as just the question and the final answer, which already carries what the tool returned that mattered. - **RAG context is never stored.** `knowledge` chunks are added to the current question only; history keeps the question as the user typed it. - **History has a token budget** (`history.maxTokens`, default 4000, estimated at ~4 characters per token). The oldest whole turns are dropped first; the latest turn is always kept. - **Optional running summary**: with `summarize: true`, dropped turns are folded into a short summary (under ~120 words) that goes into the system prompt. This costs one small model call each time the history overflows, and keeps long conversations from losing earlier facts. ```typescript const assistant = agent({ instructions: 'Help users with their questions.', memory: true, history: { maxTokens: 2000, summarize: true }, }); ``` The summary is stored next to the conversation (key `<conversation>|summary`) in the same store. The system prompt is rebuilt on every run and never stored. --- ## Database Memory Store To keep conversations across restarts and multiple servers, store them in your database (PostgreSQL, MySQL, SQLite or MongoDB). The `ai_memory` table (or collection) is created on first use. ```typescript import { DatabaseMemoryStore, agent } from 'jsango'; import { db } from './database.js'; // your DatabaseManager export const persistentAgent = agent({ name: 'SupportAgent', instructions: 'Production assistant', memory: new DatabaseMemoryStore({ connection: db, maxMessages: 100 }), }); ``` Options: `connection` (required), `tableName` (default `ai_memory`), and `maxMessages` (default 100; the oldest messages are dropped first). --- ## Custom Stores Any object with `get(key)`, `set(key, messages)` and `clear(key)` works as `memory`, for example a Redis-backed store: ```typescript import type { MemoryStore, LlmMessage } from 'jsango'; class RedisMemory implements MemoryStore { constructor( private readonly redis: { get(k: string): Promise<string | null>; set(k: string, v: string): Promise<unknown>; del(k: string): Promise<unknown>; } ) {} async get(key: string): Promise<LlmMessage[]> { return JSON.parse((await this.redis.get(`mem:${key}`)) ?? '[]'); } async set(key: string, messages: LlmMessage[]): Promise<void> { await this.redis.set(`mem:${key}`, JSON.stringify(messages)); } async clear(key: string): Promise<void> { await this.redis.del(`mem:${key}`); } } ``` --- <!-- source: docs/ai/rag.md (RAG) --> # RAG & Vector Search in JSango JSango provides native document ingestion, chunking, semantic embeddings, and vector similarity retrieval. --- ## Setting Up Knowledge Base ```typescript import { knowledge, InMemoryVectorStore } from 'jsango'; export const companyDocs = knowledge('company-docs', { vectorStore: new InMemoryVectorStore(), // default; plug in your own IVectorStore chunkSize: 500, // words per chunk chunkOverlap: 50, }); // Ingest raw text (chunked and embedded automatically) await companyDocs.ingest( 'JSango is a high-performance TypeScript backend framework with native AI agent orchestration.', { metadata: { section: 'overview' } } ); // ...or documents you have already split await companyDocs.ingest([ { id: 'faq_1', content: 'Refunds are processed within 5 business days.', metadata: { section: 'faq' }, }, ]); ``` Embeddings come from the knowledge base's `provider` (default: the default model router), so the provider must implement `embed()` (OpenAI, Gemini, Ollama and the fake provider do; for a custom provider, pass `provider(fn, { embed })`). --- ## Semantic Querying ```typescript // Scored matches const results = await companyDocs.search('What is JSango?', { limit: 3, minScore: 0.7 }); for (const match of results) { console.log(`[Score: ${match.score.toFixed(2)}] ${match.document.content}`); } // Just the matching text const passages: string[] = await companyDocs.retrieve('refund policy', 3); ``` --- ## Using Knowledge in Agents Agents can query knowledge bases directly inside their reasoning loop: ```typescript import { agent, tool, object, string } from 'jsango'; const searchKnowledge = tool({ name: 'searchDocs', description: 'Search internal technical documentation', schema: object({ query: string() }), execute: async ({ query }: { query: string }) => { return await companyDocs.retrieve(query, 3); }, }); export const docsAgent = agent({ name: 'DocsBot', instructions: 'Answer user questions using companyDocs tool.', tools: { searchKnowledge }, }); ``` --- <!-- source: docs/ai/mcp.md (MCP) --> # Model Context Protocol (MCP) in JSango JSango can **expose** its tools as an MCP server (for Claude Desktop, Cursor and other MCP clients) and **consume** tools from remote MCP servers in its agents. --- ## Exposing JSango Tools as an MCP Server ```typescript import { McpServer, tool, createApp, HttpResponse, object, string } from 'jsango'; const findUser = tool({ name: 'find_user', description: 'Lookup user by email address', schema: object({ email: string() }), execute: async ({ email }: { email: string }) => { return await User.where('email', email).first(); }, }); const server = new McpServer({ name: 'acme-tools', version: '1.0.0' }).registerTool(findUser); // Serve it over HTTP (MCP "Streamable HTTP" transport, JSON responses) const app = createApp(); app.post('/mcp', async (ctx) => { const reply = await server.handleJsonRpc(await ctx.request.json()); return reply ?? HttpResponse.noContent(); // notifications get no body }); await app.listen(3000); ``` `handleJsonRpc` implements `initialize`, `ping`, `tools/list` and `tools/call`. If a tool throws, the result has `isError: true` so the calling model can read the error. You can also call it directly, without HTTP: ```typescript const result = await server.handleJsonRpc({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'find_user', arguments: { email: 'alex@example.com' } }, }); ``` Protect the endpoint like any other route, e.g. with your auth middleware. Tools served over MCP go through the same checks as inside an agent: arguments are validated, a tool with `requiresApproval` is refused (MCP has no approval step), and a tool with `permissions` is refused because MCP callers have no identity. Put the MCP route behind `auth.required()` if it should not be public. ### Local servers (stdio) Desktop clients (Claude Code, Cursor, VS Code) start local MCP servers as a process and talk over stdin/stdout. `serveStdio()` does that; pass `instructions` to tell the model how to use your tools: ```typescript import { McpServer, tool, object, string } from 'jsango'; const server = new McpServer({ name: 'acme-tools', version: '1.0.0', instructions: 'Use find_user before changing an account.', }).registerTool( tool({ name: 'find_user', description: 'Lookup user by email address', schema: object({ email: string() }), execute: async ({ email }: { email: string }) => ({ email }), }) ); await server.serveStdio(); // log to stderr: stdout carries the protocol ``` jsango itself ships one for AI coding agents: `npx jsango mcp` (see "Building with AI assistants"). --- ## Consuming Remote MCP Tools in JSango Agents `McpClient.connect()` performs the MCP handshake. `tools()` then returns the server's tools as regular jsango tools: ```typescript import { McpClient, agent } from 'jsango'; const remote = await McpClient.connect('https://mcp.internal.company.com/mcp', { headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` }, }); const orchestrator = agent({ name: 'Orchestrator', instructions: 'Use remote MCP tools to fulfill tasks.', tools: await remote.tools(), }); // or call a tool yourself const text = await remote.callTool('find_user', { email: 'alex@example.com' }); ``` The client speaks MCP over HTTP POST and accepts both JSON and SSE (`text/event-stream`) responses, including the `Mcp-Session-Id` session header. The stdio transport (for local command-line servers) isn't included. --- <!-- source: docs/ai/human-in-the-loop.md (Human in the loop) --> # Human-in-the-Loop & Approvals in JSango Sensitive tools (e.g., issuing refunds, executing database migrations, deleting customer accounts, or sending external emails) should never run autonomously without human authorization. --- ## Configuring Tool Approval Mark any tool with `requiresApproval: true`: ```typescript import { tool } from 'jsango'; import { object, string, number } from '@jsango/validation'; export const refundTool = tool({ name: 'issueRefund', description: 'Issue refund to customer invoice', schema: object({ invoiceId: string(), amount: number(), }), requiresApproval: true, execute: async ({ invoiceId, amount }) => { return { status: 'refunded', invoiceId, amount }; }, }); ``` --- ## Handling Approval Pauses When the model calls an approval-gated tool, the agent does **not** run it. `run()` returns with `status: 'paused'` and an `approvalRequest`, and emits an `approval.required` event: ```typescript import { agent, ToolExecutor } from 'jsango'; import { refundTool } from './tools/refund.js'; // the tool defined above const supportAgent = agent({ name: 'SupportAgent', instructions: 'Handle billing queries.', tools: { refundTool }, }); const result = await supportAgent.run({ input: 'Please refund $50 for invoice inv_123' }); if (result.status === 'paused' && result.approvalRequest) { const { approvalId, toolName, input } = result.approvalRequest; // Store it and show it to a reviewer (e.g. in your admin panel)... await approvals.save({ approvalId, toolName, input }); } // Later, once a reviewer approved it, execute the tool explicitly: const outcome = await ToolExecutor.execute({ tool: refundTool, arguments: approvedRequest.input, approvalGranted: true, context: { user: reviewer }, }); console.log(outcome.output); ``` --- <!-- source: docs/ai/testing.md (Testing AI) --> # Testing AI Agents & Zero-Cost Fixtures `FakeLlmProvider` lets you test agents, tool calls and workflows deterministically, with no API calls and no cost. --- ## Deterministic Testing with FakeLlmProvider Responses are consumed in order: each `respond()` / `respondWithTool()` answers one model call. ```typescript import { describe, it, expect } from 'vitest'; import { FakeLlmProvider, agent, tool, object, string } from 'jsango'; describe('Customer Support Agent', () => { it('calls lookupOrder tool and answers user', async () => { const fake = new FakeLlmProvider() .respondWithTool('lookupOrder', { orderId: 'ord_999' }) // 1st call: use the tool .respond('Order ord_999 has been shipped and is in transit.'); // 2nd call: final answer let toolExecuted = false; const lookupOrder = tool({ name: 'lookupOrder', description: 'Lookup order status', schema: object({ orderId: string() }), execute: async ({ orderId }: { orderId: string }) => { toolExecuted = true; return { orderId, status: 'in_transit' }; }, }); const testAgent = agent({ name: 'SupportAgent', instructions: 'Assist users with orders', provider: fake, tools: { lookupOrder }, }); const result = await testAgent.run({ input: 'Where is my order ord_999?' }); expect(toolExecuted).toBe(true); expect(result.text).toContain('Order ord_999 has been shipped'); expect(result.toolCalls).toHaveLength(1); expect(result.toolCalls[0]?.name).toBe('lookupOrder'); expect(fake.callHistory).toHaveLength(2); // every prompt the model received }); }); ``` `respond()` also accepts a rule with `match` (string, RegExp or function of the request) to answer specific prompts, plus `setDefaultResponse()` for everything else. --- ## Running AI Evaluations `evaluate(name, cases, target)` runs each case against an agent (or any `(input) => Promise<string>` function) and scores it: ```typescript import { evaluate } from 'jsango'; const report = await evaluate( 'Refund agent', [ { input: 'Please cancel order 123', expectedTools: ['cancelOrder'] }, { input: 'What is your return policy?', expected: /30 days/ }, { input: 'Hi', expected: (res) => res.text.length > 0, maxDurationMs: 2000 }, ], supportAgent ); console.log(report.passed, report.score, report.errors); // passed: every case passed; score: % of passing cases (0-100); errors: failure reasons ``` --- <!-- source: docs/deployment/README.md (Deployment) --> # jsango Production Deployment Guide This guide covers best practices for deploying `jsango` applications to production environments. ## Production mode jsango runs in **production mode unless `NODE_ENV` is `development` or `test`**. A server started without `NODE_ENV` (common with Docker, PM2 or a plain `node dist/index.js`) is safe by default: - 500 responses say "An internal error occurred." (no SQL, stack traces or provider errors); - auth cookies are `Secure`; - the built-in admin account refuses to sign in without a `JSANGO_ADMIN_PASSWORD` of 12+ characters. Set `NODE_ENV=development` on your machine (projects from `jsango new` have it in `.env`). Server errors (5xx) are logged with their stack: JSON lines in production, readable text in development. Pass `createApp({ logger })` to use your own logger. --- ## 1. Production Dockerfile ```dockerfile # Build Stage FROM node:20-alpine AS builder WORKDIR /app RUN npm install -g pnpm@12.5.1 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* ./ COPY . . RUN pnpm install --frozen-lockfile RUN pnpm build # Production Stage FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production RUN npm install -g pnpm@12.5.1 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* ./ COPY --from=builder /app/dist ./dist RUN pnpm install --prod --frozen-lockfile USER node EXPOSE 3000 HEALTHCHECK --interval=15s --timeout=3s --retries=3 \ CMD wget --quiet --tries=1 --spider http://localhost:3000/health/live || exit 1 CMD ["node", "dist/index.js"] ``` --- ## 2. Reverse Proxy (Nginx / Caddy) Always run `jsango` behind a high-performance reverse proxy (e.g. Nginx or Caddy) to handle TLS termination, static asset caching, and request rate limiting. ### Nginx Example ```nginx upstream jsango_backend { server 127.0.0.1:3000; keepalive 64; } server { listen 80; server_name api.example.com; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name api.example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; location / { proxy_pass http://jsango_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` ### Trusting the proxy Behind a proxy or load balancer (Nginx, Caddy, AWS ALB, Heroku, Fly.io, Kubernetes ingress), tell jsango how many proxies sit in front of it: ```typescript import { createApp } from 'jsango'; const app = createApp({ trustProxy: true }); // one proxy; use a number for a chain (e.g. CDN + LB) ``` Then `ctx.request.ip`, `ctx.request.protocol` (`https`) and the host come from `X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host`. This is what makes login rate limits count real client IPs and cookie sessions accept `https://` origins. Without `trustProxy` these headers are ignored, because anyone can send them; never enable it when clients can reach the app directly. The keep-alive timeout defaults to 65 seconds, longer than common load balancer idle timeouts (60s), which avoids sporadic 502s. ### Security headers and rate limits ```typescript import { createApp, securityHeaders, rateLimit, DatabaseAuthStore } from 'jsango'; const app = createApp({ trustProxy: true }); app.use(securityHeaders()); // Several instances: share the counters through the database (or any store with increment()). app.use(rateLimit({ max: 300, store: new DatabaseAuthStore({ connection: db }) })); ``` --- ## 3. Graceful Shutdown & Process Management Handle `SIGTERM` and `SIGINT` in your entry point to shut down gracefully. Projects scaffolded with `jsango create` already do this for the database; extend the handler for the HTTP server and other subsystems: ```typescript import { createApp, getDatabaseManager } from 'jsango'; const app = createApp(); const server = await app.listen(3000); const shutdown = async () => { await server.close(10_000); // stop accepting connections, wait for in-flight requests, close WebSockets await getDatabaseManager().close(); // drain database connection pools process.exit(0); }; process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); ``` ### PM2 Ecosystem Example (`ecosystem.config.cjs`) ```javascript module.exports = { apps: [ { name: 'jsango-api', script: 'dist/index.js', instances: 'max', exec_mode: 'cluster', kill_timeout: 10000, env_production: { NODE_ENV: 'production', PORT: 3000, }, }, ], }; ``` --- ## 4. Health Checks & Kubernetes Probes Register these endpoints with `HealthRegistry` and `createHealthHandler()` from `@jsango/observability` (see the [Observability architecture](../architecture/observability/README.md)); they are not mounted automatically: - **Liveness Probe (`/health/live`)**: Returns HTTP `200` if the Node.js event loop is operational. - **Readiness Probe (`/health/ready`)**: Returns HTTP `200` if all critical subsystem checks (database pools, cache, queue drivers) are connected and ready to accept traffic. --- <!-- source: docs/LIMITATIONS.md (Known limitations) --> # jsango Framework Limitations & Architectural Boundaries This document provides a transparent, honest overview of the architectural limitations, runtime boundaries, and current scope of the `jsango` framework as of `0.1.0-rc.1`. --- ## 1. Runtime Environment Support - **Node.js**: Requires Node.js `>=20.0.0` (active LTS or newer) utilizing native ECMAScript Modules (`"type": "module"`). - **Bun / Deno**: The core HTTP, Container, Router, Validation, and ORM packages are designed with runtime independence abstractions (`IRuntimeAdapter`). However, dedicated Bun/Deno platform adapters are planned for post-1.0 milestones. - **CommonJS**: The framework is strictly ESM-first. CJS consumers must use dynamic `import()` to consume `@jsango/*` packages. --- ## 2. Database & ORM Boundaries - **Supported SQL Dialects**: Built-in AST compilers support standard ANSI SQL, PostgreSQL placeholder conventions (`$1`), and SQLite/MySQL parameter placeholders (`?`). - **Transactional DDL**: Distributed migration transaction guarantees depend on underlying database engine capabilities (PostgreSQL supports transactional DDL; MySQL does not). - **Implicit Lazy Loading Ban**: Implicit property-access lazy loading (e.g. `user.posts` making an implicit query) is strictly forbidden and unsupported to prevent N+1 queries. All relationship loading must be declared upfront via `.with('posts')`. --- ## 3. Real-Time & WebSockets - **Single-Node vs Multi-Node**: The default `LocalTransport` broadcasts WebSocket messages within a single Node.js process. Horizontal clustering across multiple server instances requires plugging in a distributed transport adapter (e.g. Redis pub/sub). - **Message Framing**: Built-in framing standardizes on UTF-8 JSON envelopes (`{ type, payload, requestId, metadata }`). Binary streaming protocols (e.g. raw Protobuf or binary audio streams) require custom WebSocket handlers. --- ## 4. Background Queues & Jobs - **Delivery Semantics**: The queue subsystem enforces **at-least-once** delivery. Job handlers must be written to be idempotent. - **Worker Concurrency**: Process concurrency is bounded by the Node.js event loop. CPU-intensive background tasks should be scheduled with lower concurrency or offloaded to worker threads / dedicated worker nodes. --- ## 5. Caching - **Stampede Lock Scope**: The default Promise-based stampede lock (`remember()`) is process-local. In horizontally scaled environments, multiple instances may simultaneously query the backing datastore on cache misses unless a distributed lock manager is configured. --- ## 6. Observability - **High-Cardinality Metric Label Protection**: `MetricRegistry` enforces a maximum cardinality limit (default 1,000 label permutations per metric). High-entropy inputs (such as raw user IDs or UUIDs in label keys) will be rejected to prevent unbounded memory growth. --- ## 7. Known gaps (1.6) - **Admin row-level filtering**: object-level policies (`permissions.authorizationManager`) are checked on update and delete, but list and CSV export only check the resource. Use resource permissions or separate resources until a query scope hook lands. - **Access tokens after refresh-token theft**: when a reused refresh token revokes a login, access tokens already issued for it stay valid until they expire (15 minutes by default). - **Big integers on SQLite**: values above 2^53 lose precision in the SQLite driver itself; use PostgreSQL or MySQL for `bigint` columns that large. - **Query timeouts are opt-in** (`DATABASE_QUERY_TIMEOUT` / `queryTimeoutMs`) because they also apply to migrations. - **Agent approvals**: a run paused by a tool with `requiresApproval` can't be resumed yet; start a new run after approving. - **Voice agents**: - Server-side WebRTC is only for speech-to-speech (`app.webrtcAgent()` with `openaiRealtime()`). Pipeline mode (any STT/TTS) runs over WebSocket and phone calls. - Twilio is the only phone carrier, with no DTMF (keypad) input or call transfer. - Approval-required tools are not run from voice. - Gemini Live can't cancel a reply on the provider's side. - Built-in silence detection is energy-based: noisy rooms work better with a provider that detects end of turn, e.g. Deepgram. - **AI insights** support memory and SQL stores (not MongoDB). Aggregation runs over the rows in the requested range; for millions of rows per query, query the `ai_*` tables directly. - **Swagger UI** is loaded from unpkg at a pinned version, without Subresource Integrity. - **In-memory defaults**: the queue driver, the admin audit log and (without `createAuth`) the admin session store live in memory. They warn in production; configure persistent stores for multi-instance deployments.