From bbb3ed6fe549cc7a47334b5ec61cfe5514dd7ea7 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 18:29:55 +0200 Subject: [PATCH 01/15] docs: improve runtime types --- .../runtime-types/getting-started.md | 72 +++- .../documentation/runtime-types/reflection.md | 163 ++++++++ .../runtime-types/serialization.md | 161 ++++++++ .../runtime-types/troubleshooting.md | 383 ++++++++++++++++++ .../runtime-types/type-guards.md | 295 ++++++++++++++ .../documentation/runtime-types/types.md | 198 +++++++++ .../documentation/runtime-types/validation.md | 99 ++++- 7 files changed, 1363 insertions(+), 8 deletions(-) create mode 100644 website/src/pages/documentation/runtime-types/troubleshooting.md create mode 100644 website/src/pages/documentation/runtime-types/type-guards.md diff --git a/website/src/pages/documentation/runtime-types/getting-started.md b/website/src/pages/documentation/runtime-types/getting-started.md index 319576ae2..0ecb7af5e 100644 --- a/website/src/pages/documentation/runtime-types/getting-started.md +++ b/website/src/pages/documentation/runtime-types/getting-started.md @@ -2,15 +2,14 @@ To install Deepkit's runtime type system two packages are needed: The Deepkit Type Compiler and the Deepkit Type package itself. The type compiler is a TypeScript transformer that generates runtime type information from TypeScript types. The type package contains the runtime virtual machine and type annotations as well as many useful functions for working with types. - -## Installation +## Installation ```sh npm install --save @deepkit/type npm install --save-dev @deepkit/type-compiler typescript ts-node ``` -Runtime type information is not generated by default. It must be set `"reflection": true` in the `tsconfig.json` file to enable it. +Runtime type information is not generated by default. It must be set `"reflection": true` in the `tsconfig.json` file to enable it. If decorators are to be used, `"experimentalDecorators": true` must be enabled in `tsconfig.json`. This is not strictly necessary to work with `@deepkit/type`, but necessary for certain functions of other Deepkit libraries and in Deepkit Framework. @@ -28,26 +27,91 @@ _File: tsconfig.json_ } ``` +## Quick Start Examples + +### Basic Type Casting and Validation + Write your first code with runtime type information: _File: app.ts_ ```typescript -import { cast, MinLength, ReflectionClass } from '@deepkit/type'; +import { cast, MinLength, ReflectionClass, validate, is } from '@deepkit/type'; interface User { username: string & MinLength<3>; birthDate?: Date; } +// Type casting with automatic conversion const user = cast({ username: 'Peter', birthDate: '2010-10-10T00:00:00Z' }); console.log(user); +// Output: { username: 'Peter', birthDate: 2010-10-10T00:00:00.000Z } +// Reflection - inspect types at runtime const reflection = ReflectionClass.from(); console.log(reflection.getProperty('username').type); + +// Validation - check if data matches type constraints +const errors = validate({ username: 'Jo', birthDate: new Date() }); +console.log(errors); +// Output: [{ path: 'username', code: 'minLength', message: 'Min length is 3' }] + +// Type guards - runtime type checking +if (is('hello')) { + console.log('Value is a string'); +} +``` + +### Working with Complex Types + +```typescript +import { cast, validate, Email, MaxLength } from '@deepkit/type'; + +interface Product { + id: number; + name: string & MaxLength<100>; + price: number; + tags: string[]; + metadata?: Record; +} + +interface Order { + id: number; + customerEmail: string & Email; + products: Product[]; + total: number; + createdAt: Date; +} + +// Cast complex nested data +const orderData = { + id: 1, + customerEmail: 'customer@example.com', + products: [ + { + id: 1, + name: 'Laptop', + price: 999.99, + tags: ['electronics', 'computers'], + metadata: { brand: 'TechCorp', warranty: '2 years' } + } + ], + total: 999.99, + createdAt: '2023-01-01T10:00:00Z' +}; + +const order = cast(orderData); +console.log(order.createdAt instanceof Date); // true + +// Validate the entire order +const validationErrors = validate(order); +if (validationErrors.length === 0) { + console.log('Order is valid!'); +} ``` And run it with `ts-node`: diff --git a/website/src/pages/documentation/runtime-types/reflection.md b/website/src/pages/documentation/runtime-types/reflection.md index 080966c2f..3ca29a4a8 100644 --- a/website/src/pages/documentation/runtime-types/reflection.md +++ b/website/src/pages/documentation/runtime-types/reflection.md @@ -289,3 +289,166 @@ function validate(data: any, type?: ReceiveType): void { ``` It is useful to assign the result to the same variable to avoid creating a new one unnecessarily. In `type` now either a type object is stored or an error is thrown, if for example no type argument was passed, Deepkit's type compiler was not installed correctly, or the emitting of type information is not activated (see the section Installation above). + +## Practical Reflection Examples + +### Inspecting Class Properties + +```typescript +import { ReflectionClass, typeOf } from '@deepkit/type'; +import { Group, PrimaryKey, Email } from '@deepkit/type'; + +class User { + id!: number & PrimaryKey; + username!: string; + email!: string & Email; + password!: string & Group<'secret'>; + createdAt!: Date; +} + +const reflection = ReflectionClass.from(User); + +// Get all properties +console.log('All properties:'); +reflection.getProperties().forEach(prop => { + console.log(`- ${prop.name}: ${prop.type.kind}`); +}); + +// Get properties in specific groups +console.log('Secret properties:'); +reflection.getPropertiesInGroup('secret').forEach(prop => { + console.log(`- ${prop.name}`); +}); + +// Check if property has specific annotations +const emailProp = reflection.getProperty('email'); +console.log('Email property has Email annotation:', emailProp.type.kind); + +// Get primary key properties +const primaryKeys = reflection.getPrimaries(); +console.log('Primary keys:', primaryKeys.map(p => p.name)); +``` + +### Working with Type Information + +```typescript +import { typeOf, stringifyType, ReflectionKind } from '@deepkit/type'; + +interface Product { + id: number; + name: string; + price: number; + tags: string[]; + metadata?: Record; +} + +const productType = typeOf(); + +function analyzeType(type: any, depth = 0): void { + const indent = ' '.repeat(depth); + + switch (type.kind) { + case ReflectionKind.objectLiteral: + console.log(`${indent}Object with properties:`); + type.types.forEach((prop: any) => { + console.log(`${indent} ${prop.name}${prop.optional ? '?' : ''}: `); + analyzeType(prop.type, depth + 2); + }); + break; + + case ReflectionKind.array: + console.log(`${indent}Array of:`); + analyzeType(type.type, depth + 1); + break; + + case ReflectionKind.string: + console.log(`${indent}string`); + break; + + case ReflectionKind.number: + console.log(`${indent}number`); + break; + + default: + console.log(`${indent}${stringifyType(type)}`); + } +} + +analyzeType(productType); +``` + +### Dynamic Property Access + +```typescript +import { ReflectionClass, ReflectionProperty } from '@deepkit/type'; + +class DynamicModel { + id!: number; + name!: string; + email!: string; + age!: number; +} + +const reflection = ReflectionClass.from(DynamicModel); + +function getPropertyValue(obj: any, propertyName: string): any { + const property = reflection.getProperty(propertyName); + return obj[propertyName]; +} + +function setPropertyValue(obj: any, propertyName: string, value: any): void { + const property = reflection.getProperty(propertyName); + // You could add type checking here based on property.type + obj[propertyName] = value; +} + +function getPropertyNames(): string[] { + return reflection.getProperties().map(p => p.name); +} + +// Usage +const model = new DynamicModel(); +setPropertyValue(model, 'name', 'John'); +setPropertyValue(model, 'age', 30); + +console.log('Property names:', getPropertyNames()); +console.log('Name:', getPropertyValue(model, 'name')); +console.log('Age:', getPropertyValue(model, 'age')); +``` + +### Type Comparison and Compatibility + +```typescript +import { typeOf, isSameType, stringifyType } from '@deepkit/type'; + +interface User { + id: number; + name: string; +} + +interface Person { + id: number; + name: string; +} + +interface ExtendedUser extends User { + email: string; +} + +const userType = typeOf(); +const personType = typeOf(); +const extendedUserType = typeOf(); + +console.log('User and Person are same type:', isSameType(userType, personType)); +console.log('User and ExtendedUser are same type:', isSameType(userType, extendedUserType)); + +// Compare type structures +function compareTypes(type1: any, type2: any): void { + console.log(`Type 1: ${stringifyType(type1)}`); + console.log(`Type 2: ${stringifyType(type2)}`); + console.log(`Same: ${isSameType(type1, type2)}`); +} + +compareTypes(userType, personType); +compareTypes(userType, extendedUserType); +``` diff --git a/website/src/pages/documentation/runtime-types/serialization.md b/website/src/pages/documentation/runtime-types/serialization.md index 72a3ed9c9..515d06d28 100644 --- a/website/src/pages/documentation/runtime-types/serialization.md +++ b/website/src/pages/documentation/runtime-types/serialization.md @@ -165,5 +165,166 @@ In the case of invalid data, no attempt is made to convert it and instead an err ### Embedded +## Advanced Serialization Examples + +### Working with Classes + +```typescript +import { cast, serialize, deserialize } from '@deepkit/type'; + +class User { + created: Date = new Date(); + + constructor(public username: string) {} +} + +// Deserialize to class instance +const user = cast({ + username: 'Peter', + created: '2021-10-19T00:22:58.257Z' +}); + +console.log(user instanceof User); // true +console.log(user.created instanceof Date); // true + +// Serialize class instance +const json = serialize(user); +console.log(json); +// { username: 'Peter', created: '2021-10-19T00:22:58.257Z' } +``` + +### Groups for Selective Serialization + +Groups allow you to serialize only specific properties based on context: + +```typescript +import { cast, serialize, Group } from '@deepkit/type'; + +class Settings { + weight: string & Group<'privateSettings'> = '12g'; + color: string = 'red'; +} + +class User { + id: number = 0; + password: string & Group<'secret'> = ''; + settings: Settings = new Settings(); + + constructor(public username: string & Group<'public'>) {} +} + +const user = new User('john'); +user.password = 'secret123'; + +// Serialize only public information +const publicData = serialize(user, { groupsExclude: ['secret', 'privateSettings'] }); +console.log(publicData); +// { id: 0, username: 'john', settings: { color: 'red' } } + +// Serialize only specific groups +const secretData = serialize(user, { groups: ['secret'] }); +console.log(secretData); +// { password: 'secret123' } +``` + +### Custom Serialization for Complex Types + +```typescript +import { serializer, executeTypeArgumentAsArray } from '@deepkit/type'; + +class MyIterable implements Iterable { + items: T[] = []; + + constructor(items: T[] = []) { + this.items = items; + } + + [Symbol.iterator](): Iterator { + return this.items[Symbol.iterator](); + } + + add(item: T) { + this.items.push(item); + } +} + +// Register custom deserializer +serializer.deserializeRegistry.registerClass(MyIterable, (type, state) => { + executeTypeArgumentAsArray(type, 0, state); + state.convert(value => new MyIterable(value)); +}); + +// Register custom serializer +serializer.serializeRegistry.registerClass(MyIterable, (type, state) => { + state.convert((value: MyIterable) => value.items); + executeTypeArgumentAsArray(type, 0, state); +}); + +// Usage +const iterable = deserialize>(['a', 'b', 'c']); +console.log(iterable instanceof MyIterable); // true +console.log([...iterable]); // ['a', 'b', 'c'] + +const serialized = serialize>(iterable); +console.log(serialized); // ['a', 'b', 'c'] +``` + +### Error Handling in Serialization + +```typescript +import { cast, ValidationError } from '@deepkit/type'; + +interface Product { + id: number; + name: string; + price: number; +} + +try { + const product = cast({ + id: 'invalid', // Should be number + name: 'Laptop', + price: 999.99 + }); +} catch (error) { + if (error instanceof ValidationError) { + console.log('Validation errors:'); + error.errors.forEach(err => { + console.log(`- ${err.path}: ${err.message}`); + }); + // Output: + // - id: Not a number + } +} +``` + ## Naming Strategy +Naming strategies allow you to transform property names during serialization: + +```typescript +import { serialize, deserialize, underscoreNamingStrategy } from '@deepkit/type'; + +interface User { + firstName: string; + lastName: string; + emailAddress: string; +} + +const user: User = { + firstName: 'John', + lastName: 'Doe', + emailAddress: 'john@example.com' +}; + +// Serialize with underscore naming +const json = serialize(user, { namingStrategy: underscoreNamingStrategy }); +console.log(json); +// { first_name: 'John', last_name: 'Doe', email_address: 'john@example.com' } + +// Deserialize with underscore naming +const backToUser = deserialize(json, { namingStrategy: underscoreNamingStrategy }); +console.log(backToUser); +// { firstName: 'John', lastName: 'Doe', emailAddress: 'john@example.com' } +``` + diff --git a/website/src/pages/documentation/runtime-types/troubleshooting.md b/website/src/pages/documentation/runtime-types/troubleshooting.md new file mode 100644 index 000000000..d802fa6af --- /dev/null +++ b/website/src/pages/documentation/runtime-types/troubleshooting.md @@ -0,0 +1,383 @@ +# Troubleshooting + +This guide covers common issues and their solutions when working with Deepkit's runtime type system. + +## Installation Issues + +### Type Compiler Not Working + +**Problem**: Runtime type information is not available, getting errors like "No type received" or types are not being reflected properly. + +**Solutions**: + +1. **Check tsconfig.json configuration**: + ```json + { + "compilerOptions": { + "experimentalDecorators": true + }, + "reflection": true + } + ``` + +2. **Verify type compiler installation**: + ```bash + # Check if type compiler is installed + npm list @deepkit/type-compiler + + # Reinstall if necessary + npm install --save-dev @deepkit/type-compiler + + # Manually install the compiler + node_modules/.bin/deepkit-type-install + ``` + +3. **Check TypeScript version compatibility**: + ```bash + # The type compiler needs to match your TypeScript version + npm install --save-dev typescript@latest @deepkit/type-compiler@latest + ``` + +### Build Tool Integration Issues + +**Problem**: Type compiler doesn't work with your build tool (Webpack, Vite, etc.). + +**Solutions**: + +1. **For Webpack with ts-loader**: + ```javascript + const typeCompiler = require('@deepkit/type-compiler'); + + module.exports = { + module: { + rules: [{ + test: /\.tsx?$/, + use: { + loader: 'ts-loader', + options: { + getCustomTransformers: (program, getProgram) => ({ + before: [typeCompiler.transformer], + afterDeclarations: [typeCompiler.declarationTransformer], + }), + } + } + }] + } + }; + ``` + +2. **For Vite**: + ```typescript + import { defineConfig } from 'vite'; + import { deepkitType } from '@deepkit/vite'; + + export default defineConfig({ + plugins: [deepkitType()] + }); + ``` + +## Runtime Errors + +### ValidationError: No type received + +**Problem**: Getting "No type received" error when calling validation functions. + +**Cause**: The type compiler is not generating runtime type information. + +**Solutions**: + +1. **Ensure proper import**: + ```typescript + // Correct + import { validate } from '@deepkit/type'; + validate(data); + + // Incorrect - missing type argument + validate(data); + ``` + +2. **Check if type is properly defined**: + ```typescript + // Make sure the type is exported and accessible + export interface MyType { + id: number; + name: string; + } + ``` + +3. **Verify compilation**: + ```bash + # Compile with tsc to check for errors + npx tsc --noEmit + ``` + +### Circular Reference Errors + +**Problem**: Getting circular reference errors when working with self-referencing types. + +**Example**: +```typescript +interface User { + id: number; + name: string; + supervisor?: User; // Circular reference +} +``` + +**Solutions**: + +1. **Use proper type definitions**: + ```typescript + import { typeOf } from '@deepkit/type'; + + interface User { + id: number; + name: string; + supervisor?: User; + } + + // This works correctly with circular references + const userType = typeOf(); + ``` + +2. **For complex circular references, use forward declarations**: + ```typescript + interface Department { + id: number; + name: string; + head: User; + employees: User[]; + } + + interface User { + id: number; + name: string; + department: Department; + } + ``` + +## Performance Issues + +### Slow Validation/Serialization + +**Problem**: Type validation or serialization is slower than expected. + +**Solutions**: + +1. **Cache type information**: + ```typescript + import { typeOf, ReflectionClass } from '@deepkit/type'; + + // Cache frequently used types + const userType = typeOf(); + const userReflection = ReflectionClass.from(); + + // Reuse cached types + function validateUser(data: unknown) { + return validate(data, userType); + } + ``` + +2. **Use type guards for simple checks**: + ```typescript + import { is } from '@deepkit/type'; + + // Faster for simple type checks + if (is(value)) { + // Handle string + } + + // Instead of full validation for simple cases + const errors = validate(value); + ``` + +3. **Optimize complex types**: + ```typescript + // Instead of validating entire complex objects + interface ComplexUser { + // ... many properties + } + + // Validate only what you need + type UserBasics = Pick; + const errors = validate(data); + ``` + +## Type Definition Issues + +### Generic Type Problems + +**Problem**: Generic types are not working as expected. + +**Solutions**: + +1. **Ensure proper generic constraints**: + ```typescript + // Correct + interface Repository { + items: T[]; + find(id: string): T | undefined; + } + + // Usage + const userRepo = cast>(data); + ``` + +2. **Use explicit type parameters**: + ```typescript + function processItems(items: T[]): T[] { + return items.filter(item => item !== null); + } + + // Explicit type parameter + const result = processItems(users); + ``` + +### Union Type Issues + +**Problem**: Union types are not being validated correctly. + +**Solutions**: + +1. **Use discriminated unions**: + ```typescript + // Good - discriminated union + type ApiResponse = + | { success: true; data: any } + | { success: false; error: string }; + + // Instead of + type BadApiResponse = { + success: boolean; + data?: any; + error?: string; + }; + ``` + +2. **Ensure all union members are distinct**: + ```typescript + type Status = 'pending' | 'approved' | 'rejected'; + + // This works well with validation + const status = cast('pending'); + ``` + +## Common Validation Errors + +### Custom Validator Issues + +**Problem**: Custom validators are not working or throwing unexpected errors. + +**Solutions**: + +1. **Proper validator function signature**: + ```typescript + import { ValidatorError } from '@deepkit/type'; + + // Correct signature + function customValidator(value: any, type: any, ...args: any[]) { + if (/* validation logic */) { + return undefined; // Valid + } + return new ValidatorError('code', 'Error message'); + } + ``` + +2. **Handle edge cases**: + ```typescript + function emailValidator(value: any) { + // Always check type first + if (typeof value !== 'string') { + return new ValidatorError('type', 'Expected string'); + } + + if (!value.includes('@')) { + return new ValidatorError('email', 'Invalid email format'); + } + + return undefined; + } + ``` + +### Constraint Validation Problems + +**Problem**: Built-in constraints like MinLength, MaxLength are not working. + +**Solutions**: + +1. **Correct constraint usage**: + ```typescript + import { MinLength, MaxLength } from '@deepkit/type'; + + // Correct + type Username = string & MinLength<3> & MaxLength<20>; + + // Incorrect + type BadUsername = string | MinLength<3>; // Should use &, not | + ``` + +2. **Check constraint compatibility**: + ```typescript + import { Positive, integer } from '@deepkit/type'; + + // Correct - constraints that make sense together + type UserId = number & integer & Positive; + + // Incorrect - conflicting constraints + type BadId = string & Positive; // Positive only works with numbers + ``` + +## Debugging Tips + +### Enable Debug Logging + +```typescript +// Set environment variable for debug output +process.env.DEBUG = 'deepkit:type'; + +// Or use console logging in custom validators +function debugValidator(value: any, type: any) { + console.log('Validating:', value, 'against type:', type); + // ... validation logic +} +``` + +### Inspect Type Information + +```typescript +import { typeOf, stringifyType } from '@deepkit/type'; + +// Debug type information +const type = typeOf(); +console.log('Type structure:', JSON.stringify(type, null, 2)); +console.log('Type string:', stringifyType(type)); +``` + +### Test Type Definitions + +```typescript +import { validate, is } from '@deepkit/type'; + +// Create test cases for your types +function testUserType() { + const validUser = { id: 1, name: 'John', email: 'john@example.com' }; + const invalidUser = { id: 'invalid', name: '', email: 'not-email' }; + + console.log('Valid user errors:', validate(validUser)); + console.log('Invalid user errors:', validate(invalidUser)); + + console.log('Is valid user?', is(validUser)); + console.log('Is invalid user?', is(invalidUser)); +} + +testUserType(); +``` + +## Getting Help + +If you're still experiencing issues: + +1. **Check the GitHub issues**: [Deepkit Framework Issues](https://github.com/deepkit/deepkit-framework/issues) +2. **Join the Discord community**: [Deepkit Discord](https://discord.gg/U24mryk7Wq) +3. **Review the test files**: The test files in the repository contain many examples of correct usage +4. **Create a minimal reproduction**: When reporting issues, create a minimal example that demonstrates the problem diff --git a/website/src/pages/documentation/runtime-types/type-guards.md b/website/src/pages/documentation/runtime-types/type-guards.md new file mode 100644 index 000000000..6fdc961e5 --- /dev/null +++ b/website/src/pages/documentation/runtime-types/type-guards.md @@ -0,0 +1,295 @@ +# Type Guards and Assertions + +Type guards and assertions are essential tools for runtime type checking in TypeScript. Deepkit provides powerful functions that not only check types at runtime but also provide TypeScript with the necessary type information for proper type narrowing. + +## Type Guards with `is` + +The `is` function is a type guard that returns `true` if the value matches the specified type, and `false` otherwise. When used in conditional statements, TypeScript automatically narrows the type. + +### Basic Type Guards + +```typescript +import { is } from '@deepkit/type'; + +function processValue(value: unknown) { + if (is(value)) { + // TypeScript knows value is string here + console.log(value.toUpperCase()); + } + + if (is(value)) { + // TypeScript knows value is number here + console.log(value.toFixed(2)); + } +} + +processValue('hello'); // Outputs: HELLO +processValue(42.567); // Outputs: 42.57 +``` + +### Complex Type Guards + +Type guards work with any TypeScript type, including interfaces, classes, and complex nested structures: + +```typescript +import { is, Email, MinLength } from '@deepkit/type'; + +interface User { + id: number; + email: string & Email; + username: string & MinLength<3>; + profile?: { + firstName: string; + lastName: string; + age: number; + }; +} + +function handleUserData(data: unknown) { + if (is(data)) { + // TypeScript knows data is User here + console.log(`User: ${data.username} (${data.email})`); + + if (data.profile) { + console.log(`Name: ${data.profile.firstName} ${data.profile.lastName}`); + } + } else { + console.log('Invalid user data'); + } +} + +// Valid user data +handleUserData({ + id: 1, + email: 'john@example.com', + username: 'john_doe', + profile: { + firstName: 'John', + lastName: 'Doe', + age: 30 + } +}); + +// Invalid user data +handleUserData({ + id: 1, + email: 'invalid-email', // Fails Email validation + username: 'jo' // Fails MinLength<3> validation +}); +``` + +### Enum Type Guards + +Type guards work seamlessly with TypeScript enums: + +```typescript +import { is } from '@deepkit/type'; + +enum UserRole { + ADMIN = 'admin', + USER = 'user', + MODERATOR = 'moderator' +} + +enum Status { + ACTIVE, + INACTIVE, + PENDING +} + +function checkRole(value: unknown) { + if (is(value)) { + console.log(`Valid role: ${value}`); + return value; + } + throw new Error('Invalid role'); +} + +function checkStatus(value: unknown) { + if (is(value)) { + console.log(`Valid status: ${Status[value]}`); + return value; + } + throw new Error('Invalid status'); +} + +checkRole('admin'); // Valid +checkRole('invalid'); // Throws error + +checkStatus(0); // Valid (ACTIVE) +checkStatus(3); // Throws error +``` + +## Type Assertions with `assert` + +The `assert` function throws an error if the value doesn't match the specified type. It's useful when you want to ensure type safety and fail fast on invalid data. + +### Basic Assertions + +```typescript +import { assert } from '@deepkit/type'; + +function processUser(userData: unknown) { + // Throws error if userData is not a valid User + assert(userData); + + // TypeScript knows userData is User from this point + console.log(`Processing user: ${userData.username}`); + return userData; +} + +try { + processUser({ + id: 1, + email: 'john@example.com', + username: 'john_doe' + }); +} catch (error) { + console.error('Invalid user data:', error.message); +} +``` + +### Assertion with Custom Error Messages + +```typescript +import { assert, ValidationError } from '@deepkit/type'; + +function validateApiResponse(response: unknown) { + try { + assert<{ + success: boolean; + data: any; + message?: string; + }>(response); + + return response; + } catch (error) { + if (error instanceof ValidationError) { + throw new Error(`API response validation failed: ${error.message}`); + } + throw error; + } +} +``` + +## Advanced Type Guard Patterns + +### Union Type Guards + +Type guards can distinguish between union types: + +```typescript +import { is } from '@deepkit/type'; + +type ApiResponse = + | { success: true; data: any } + | { success: false; error: string }; + +function handleResponse(response: unknown) { + if (is(response)) { + if (response.success) { + // TypeScript knows this is the success case + console.log('Data:', response.data); + } else { + // TypeScript knows this is the error case + console.error('Error:', response.error); + } + } +} +``` + +### Generic Type Guards + +You can create reusable type guard functions: + +```typescript +import { is } from '@deepkit/type'; + +function isArrayOf(value: unknown, itemGuard: (item: unknown) => item is T): value is T[] { + return Array.isArray(value) && value.every(itemGuard); +} + +// Usage with primitive types +function isStringArray(value: unknown): value is string[] { + return isArrayOf(value, (item): item is string => is(item)); +} + +// Usage with complex types +interface Product { + id: number; + name: string; + price: number; +} + +function isProductArray(value: unknown): value is Product[] { + return isArrayOf(value, (item): item is Product => is(item)); +} + +const data: unknown = [ + { id: 1, name: 'Laptop', price: 999 }, + { id: 2, name: 'Mouse', price: 25 } +]; + +if (isProductArray(data)) { + // TypeScript knows data is Product[] + data.forEach(product => { + console.log(`${product.name}: $${product.price}`); + }); +} +``` + +## Performance Considerations + +Type guards and assertions are highly optimized in Deepkit: + +- **JIT Compilation**: Type checking functions are compiled just-in-time for maximum performance +- **Caching**: Type information is cached to avoid recompilation +- **Minimal Overhead**: Runtime checks add minimal performance overhead + +```typescript +import { is } from '@deepkit/type'; + +// This is very fast - the type check is compiled once and cached +const isUser = (value: unknown): value is User => is(value); + +// Use the cached type guard in performance-critical code +function processUsers(users: unknown[]) { + return users.filter(isUser).map(user => ({ + id: user.id, + displayName: user.username + })); +} +``` + +## Best Practices + +1. **Use Type Guards for Unknown Data**: Always use type guards when dealing with data from external sources (APIs, user input, files). + +2. **Prefer `is` for Conditional Logic**: Use `is` when you need to handle both valid and invalid cases. + +3. **Use `assert` for Fail-Fast Behavior**: Use `assert` when invalid data should cause the program to stop. + +4. **Combine with Validation**: For user-facing applications, combine type guards with the validation system for better error messages. + +```typescript +import { is, validate } from '@deepkit/type'; + +function safeProcessUser(userData: unknown) { + // Quick type check first + if (!is(userData)) { + return { success: false, error: 'Invalid user data structure' }; + } + + // Detailed validation for user feedback + const errors = validate(userData); + if (errors.length > 0) { + return { + success: false, + error: 'Validation failed', + details: errors + }; + } + + return { success: true, user: userData }; +} +``` diff --git a/website/src/pages/documentation/runtime-types/types.md b/website/src/pages/documentation/runtime-types/types.md index 97490f7b3..393858372 100644 --- a/website/src/pages/documentation/runtime-types/types.md +++ b/website/src/pages/documentation/runtime-types/types.md @@ -487,3 +487,201 @@ groupAnnotation.getAnnotations(type); //['a', 'b'] ``` See [Runtime Types Reflection](./reflection.md) to learn more. + +## Advanced Type Annotations + +### Database Annotations + +These annotations are primarily used by Deepkit ORM but can be useful for documentation and tooling: + +```typescript +import { PrimaryKey, AutoIncrement, Unique, Index, Reference } from '@deepkit/type'; + +class User { + id!: number & PrimaryKey & AutoIncrement; + username!: string & Unique; + email!: string & Unique & Index; + profileId?: number & Reference; +} + +class Profile { + id!: number & PrimaryKey & AutoIncrement; + firstName!: string & Index; + lastName!: string & Index; + bio?: string; +} +``` + +### Validation Annotations + +Comprehensive validation constraints for different data types: + +```typescript +import { + MinLength, MaxLength, Pattern, Email, + Minimum, Maximum, Positive, Negative, + MinItems, MaxItems, Validate +} from '@deepkit/type'; + +interface UserProfile { + // String constraints + username: string & MinLength<3> & MaxLength<20> & Pattern<'^[a-zA-Z0-9_]+$'>; + email: string & Email; + + // Number constraints + age: number & Minimum<0> & Maximum<150>; + score: number & Positive; + + // Array constraints + tags: string[] & MinItems<1> & MaxItems<10>; + + // Custom validation + password: string & Validate; +} + +function validatePassword(value: any): ValidatorError | void { + if (typeof value !== 'string') return new ValidatorError('type', 'Must be string'); + if (value.length < 8) return new ValidatorError('minLength', 'Min 8 characters'); + if (!/[A-Z]/.test(value)) return new ValidatorError('uppercase', 'Must contain uppercase'); + if (!/[0-9]/.test(value)) return new ValidatorError('number', 'Must contain number'); +} +``` + +### Serialization Annotations + +Control how types are serialized and deserialized: + +```typescript +import { Excluded, Group, MapName, Embedded } from '@deepkit/type'; + +class User { + id!: number; + + @MapName('user_name') + username!: string; + + password!: string & Excluded; // Never serialized + + internalNotes!: string & Group<'internal'>; // Only in internal group + + profile!: Profile & Embedded; // Embedded in parent object +} + +class Profile { + firstName!: string; + lastName!: string; + avatar?: string; +} + +// Serialization with groups +const publicUser = serialize(user, { groupsExclude: ['internal'] }); +// { id: 1, user_name: 'john', profile: { firstName: 'John', lastName: 'Doe' } } + +const internalUser = serialize(user, { groups: ['internal'] }); +// { internalNotes: 'Some notes' } +``` + +### Type Branding and Nominal Types + +Create distinct types that are structurally identical but semantically different: + +```typescript +import { Brand } from '@deepkit/type'; + +type UserId = number & Brand<'UserId'>; +type ProductId = number & Brand<'ProductId'>; +type Email = string & Brand<'Email'>; + +// These are different types even though they're all numbers/strings +function getUser(id: UserId): User { /* ... */ } +function getProduct(id: ProductId): Product { /* ... */ } + +const userId: UserId = 123 as UserId; +const productId: ProductId = 456 as ProductId; + +getUser(userId); // ✓ Correct +getUser(productId); // ✗ TypeScript error - wrong brand + +// Email branding with validation +function createEmail(value: string): Email { + if (!value.includes('@')) { + throw new Error('Invalid email format'); + } + return value as Email; +} +``` + +### Complex Type Combinations + +Combine multiple annotations for sophisticated type definitions: + +```typescript +import { + PrimaryKey, AutoIncrement, MinLength, MaxLength, + Email, Group, Excluded, Optional, Index +} from '@deepkit/type'; + +class BlogPost { + id!: number & PrimaryKey & AutoIncrement; + + title!: string & MinLength<5> & MaxLength<200> & Index; + + slug!: string & MinLength<5> & MaxLength<200> & Unique & Index; + + content!: string & MinLength<10>; + + authorEmail!: string & Email & Index; + + publishedAt?: Date & Group<'published'>; + + draft!: boolean & Group<'internal'>; + + internalNotes?: string & Group<'internal'> & Excluded; + + tags!: string[] & MinItems<1> & MaxItems<20>; + + metadata?: Record & Group<'admin'>; +} + +// Usage with different serialization contexts +const publicPost = serialize(post, { + groupsExclude: ['internal', 'admin'] +}); + +const adminPost = serialize(post, { + groups: ['published', 'internal', 'admin'] +}); +``` + +### Runtime Type Inspection + +Access type annotations at runtime for dynamic behavior: + +```typescript +import { ReflectionClass, groupAnnotation, excludedAnnotation } from '@deepkit/type'; + +class DataProcessor { + static processEntity(entityClass: ClassType, data: any) { + const reflection = ReflectionClass.from(entityClass); + + // Get all non-excluded properties + const serializableProps = reflection.getProperties().filter(prop => + !excludedAnnotation.getFirst(prop.type) + ); + + // Process by groups + const publicProps = reflection.getPropertiesInGroup('public'); + const internalProps = reflection.getPropertiesInGroup('internal'); + + console.log('Serializable properties:', serializableProps.map(p => p.name)); + console.log('Public properties:', publicProps.map(p => p.name)); + console.log('Internal properties:', internalProps.map(p => p.name)); + + return { + public: serialize(data, { groups: ['public'] }), + internal: serialize(data, { groups: ['internal'] }), + full: serialize(data, { groupsExclude: [] }) + }; + } +} +``` diff --git a/website/src/pages/documentation/runtime-types/validation.md b/website/src/pages/documentation/runtime-types/validation.md index e85a16709..15e0367fc 100644 --- a/website/src/pages/documentation/runtime-types/validation.md +++ b/website/src/pages/documentation/runtime-types/validation.md @@ -41,18 +41,109 @@ All three functions are used in roughly the same way. The type is specified or r ```typescript import { validate, is, assert } from '@deepkit/type'; -const errors = validate('abc'); //[] -const errors = validate(123); //[{code: 'type', message: 'Not a string'}] +// Basic validation - returns array of errors +const errors1 = validate('abc'); // [] +const errors2 = validate(123); // [{code: 'type', message: 'Not a string', path: '', value: 123}] +// Type guard - returns boolean and narrows type if (is(value)) { // value is guaranteed to be a string + console.log(value.toUpperCase()); } +// Type assertion - throws error if invalid function doSomething(value: any) { - assert(value); //throws on invalid data + assert(value); // throws ValidationError on invalid data + // value is guaranteed to be a string from this point + return value.toUpperCase(); +} +``` - // value is guaranteed to be a string +### Primitive Type Validation + +```typescript +import { validate } from '@deepkit/type'; + +// String validation +console.log(validate('Hello')); // [] +console.log(validate(123)); +// [{ code: 'type', message: 'Not a string', path: '', value: 123 }] + +// Number validation +console.log(validate(123)); // [] +console.log(validate('Hello')); +// [{ code: 'type', message: 'Not a number', path: '', value: 'Hello' }] + +// Boolean validation +console.log(validate(true)); // [] +console.log(validate('true')); +// [{ code: 'type', message: 'Not a boolean', path: '', value: 'true' }] +``` + +### Built-in Validator Constraints + +Deepkit provides many built-in validator constraints that can be applied to types: + +```typescript +import { validate, Email, MinLength, MaxLength, Positive } from '@deepkit/type'; + +// Email validation +type UserEmail = string & Email; +console.log(validate('user@example.com')); // [] +console.log(validate('invalid-email')); +// [{ path: '', code: 'pattern', message: 'Pattern ^\\S+@\\S+$ does not match', value: 'invalid-email' }] + +// String length validation +type Username = string & MinLength<3>; +console.log(validate('john')); // [] +console.log(validate('jo')); +// [{ path: '', code: 'minLength', message: 'Min length is 3', value: 'jo' }] + +// Number validation +type Price = number & Positive; +console.log(validate(99.99)); // [] +console.log(validate(-10)); +// [{ path: '', code: 'positive', message: 'Number must be positive', value: -10 }] + +// Combined constraints +type ProductName = string & MinLength<2> & MaxLength<100>; +console.log(validate('Laptop')); // [] +console.log(validate('A')); +// [{ path: '', code: 'minLength', message: 'Min length is 2', value: 'A' }] +``` + +### Custom Validators + +You can create custom validators for specific business logic: + +```typescript +import { validate, Validate, ValidatorError } from '@deepkit/type'; + +// Pre-defined validator function +function startsWith(prefix: string) { + return (value: any) => { + const valid = typeof value === 'string' && value.startsWith(prefix); + return valid ? undefined : new ValidatorError('startsWith', `Does not start with ${prefix}`); + }; +} + +const startsWithA = startsWith('a'); +type MyType = string & Validate; + +console.log(validate('apple')); // [] +console.log(validate('banana')); +// [{ path: '', code: 'startsWith', message: 'Does not start with a', value: 'banana' }] + +// Validator with arguments +function startsWithLetter(value: any, type: any, letter: string) { + const valid = typeof value === 'string' && value.startsWith(letter); + return valid ? undefined : new ValidatorError('startsWith', `Does not start with ${letter}`); } + +type StartsWithB = string & Validate; +console.log(validate('banana')); // [] +console.log(validate('apple')); +// [{ path: '', code: 'startsWith', message: 'Does not start with b', value: 'apple' }] ``` If you work with more complex types like classes or interfaces, the array can also contain several entries. From 01ac223912cb762ff2062767768ae76363d6297c Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 18:51:09 +0200 Subject: [PATCH 02/15] docs: improve app --- website/src/pages/documentation/app.md | 183 +++++- .../src/pages/documentation/app/arguments.md | 138 +++++ .../pages/documentation/app/best-practices.md | 570 ++++++++++++++++++ .../pages/documentation/app/configuration.md | 229 ++++++- website/src/pages/documentation/app/events.md | 240 ++++++++ .../src/pages/documentation/app/lifecycle.md | 352 +++++++++++ .../src/pages/documentation/app/modules.md | 302 ++++++++++ .../src/pages/documentation/app/testing.md | 337 +++++++++++ .../documentation/app/troubleshooting.md | 457 ++++++++++++++ 9 files changed, 2794 insertions(+), 14 deletions(-) create mode 100644 website/src/pages/documentation/app/best-practices.md create mode 100644 website/src/pages/documentation/app/lifecycle.md create mode 100644 website/src/pages/documentation/app/testing.md create mode 100644 website/src/pages/documentation/app/troubleshooting.md diff --git a/website/src/pages/documentation/app.md b/website/src/pages/documentation/app.md index df7b22939..a5567f1f9 100644 --- a/website/src/pages/documentation/app.md +++ b/website/src/pages/documentation/app.md @@ -154,15 +154,61 @@ In Deepkit everything is now done via this `app.ts`. You can rename the file as Deepkit App automatically converts function parameters into CLI arguments and flags. The order of the parameters dictates the order of the CLI arguments -Parameters can be any TypeScript type and are automatically validated and deserialized. +Parameters can be any TypeScript type and are automatically validated and deserialized. -See the chapter [Arguments & Flags](./app/arguments.md) for more information. +```typescript +// Simple arguments +app.command('greet', (name: string, age?: number) => { + console.log(`Hello ${name}${age ? `, you are ${age}` : ''}`); +}); + +// Flags with validation +import { Flag, Email, Positive } from '@deepkit/app'; +app.command('create-user', ( + email: string & Email & Flag, + age: number & Positive & Flag, + admin: boolean & Flag = false +) => { + console.log(`Creating user: ${email}, age: ${age}, admin: ${admin}`); +}); +``` -## Dependency Injection +See the chapter [Arguments & Flags](./app/arguments.md) for comprehensive examples and advanced usage. -Deepkit App sets up a service container and for each imported module its own Dependency Injection container that inherits from its parents. -It brings out of the box following providers you can automatically inject into your services, controllers, and event listeners: +## Services & Dependency Injection + +Deepkit App provides a powerful dependency injection system that automatically resolves and injects dependencies: + +```typescript +class UserService { + users: string[] = []; + + addUser(name: string) { + this.users.push(name); + return `User ${name} added`; + } +} + +class EmailService { + sendEmail(to: string, subject: string) { + console.log(`Sending email to ${to}: ${subject}`); + } +} + +const app = new App({ + providers: [UserService, EmailService] +}); + +// Functional command with DI +app.command('add-user', (name: string, userService: UserService, emailService: EmailService) => { + const result = userService.addUser(name); + emailService.sendEmail(`${name}@example.com`, 'Welcome!'); + console.log(result); +}); +``` + +Built-in providers available out of the box: - `Logger` for logging - `EventDispatcher` for event handling @@ -170,19 +216,130 @@ It brings out of the box following providers you can automatically inject into y - `MiddlewareRegistry` for registered middleware - `InjectorContext` for the current injector context -As soon as you import Deepkit Framework you get additional providers. See [Deepkit Framework](./framework.md) for more details. +See the chapters [Services](./app/services.md) and [Dependency Injection](./app/dependency-injection.md) for more details. + +## Configuration + +Type-safe configuration with automatic environment variable loading: + +```typescript +import { MinLength } from '@deepkit/type'; + +class AppConfig { + appName: string & MinLength<3> = 'MyApp'; + port: number = 3000; + debug: boolean = false; + databaseUrl!: string; // Required +} + +const app = new App({ + config: AppConfig +}) +.loadConfigFromEnv() // Loads APP_* environment variables +.configure({ debug: true }); // Override programmatically + +app.command('show-config', (config: AppConfig) => { + console.log('App configuration:', config); +}); +``` +See the chapter [Configuration](./app/configuration.md) for advanced configuration patterns. -## Exit code +## Modules -The exit code is 0 by default, which means that the command was executed successfully. To change the exit code, a number other than 0 should be returned in the exucute method. +Organize your application into reusable modules: ```typescript -@cli.controller('test') -export class TestCommand { - async execute() { - console.error('Error :('); - return 12; +import { createModuleClass } from '@deepkit/app'; + +export class DatabaseModule extends createModuleClass({ + providers: [DatabaseService], + exports: [DatabaseService] +}) {} + +export class UserModule extends createModuleClass({ + imports: [new DatabaseModule()], + providers: [UserService], + controllers: [UserController] +}) {} + +const app = new App({ + imports: [new UserModule()] +}); +``` + +See the chapter [Modules](./app/modules.md) for module patterns and best practices. + +## Event System + +Handle application events and create custom event-driven architectures: + +```typescript +import { EventToken, onAppExecute } from '@deepkit/app'; + +const UserCreated = new EventToken('user.created'); + +app.listen(onAppExecute, (event) => { + console.log('Command starting:', event.command); +}); + +app.listen(UserCreated, (event, logger: Logger) => { + logger.log('User created:', event.data); +}); + +app.command('create-user', async (name: string, eventDispatcher: EventDispatcher) => { + // Create user logic... + await eventDispatcher.dispatch(UserCreated, { name }); +}); +``` + +See the chapter [Events](./app/events.md) for comprehensive event handling. + +## Testing + +Deepkit App provides excellent testing support: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +test('user creation command', async () => { + const testing = createTestingApp({ + providers: [UserService], + controllers: [CreateUserCommand] + }); + + const exitCode = await testing.app.execute(['create-user', 'John']); + expect(exitCode).toBe(0); + + const userService = testing.app.get(UserService); + expect(userService.users).toContain('John'); +}); +``` + +See the chapter [Testing](./app/testing.md) for comprehensive testing strategies. + +## Exit Codes + +Control command exit codes for proper CLI behavior: + +```typescript +@cli.controller('deploy') +export class DeployCommand { + async execute(environment: string): Promise { + try { + await this.deployToEnvironment(environment); + console.log('Deployment successful'); + return 0; // Success + } catch (error) { + console.error('Deployment failed:', error.message); + return 1; // Error + } } } ``` + +## Advanced Topics + +- **[Application Lifecycle](./app/lifecycle.md)**: Understanding startup, shutdown, and lifecycle events +- **[Best Practices](./app/best-practices.md)**: Patterns and recommendations for robust applications +- **[Troubleshooting](./app/troubleshooting.md)**: Common issues and debugging techniques diff --git a/website/src/pages/documentation/app/arguments.md b/website/src/pages/documentation/app/arguments.md index a69277d8e..ee3ad706c 100644 --- a/website/src/pages/documentation/app/arguments.md +++ b/website/src/pages/documentation/app/arguments.md @@ -51,6 +51,24 @@ and are automatically validated and deserialized. The order of the parameters di As soon as a complex object (interface, class, object literal) is defined, it is treated as a service dependency and the Dependency Injection Container tries to resolve it. See the chapter [Dependency Injection](dependency-injection.md) for more information. +## Multiple Arguments + +You can define multiple arguments by adding more parameters to your function or method: + +```typescript +new App().command('greet', (firstName: string, lastName: string, age?: number) => { + console.log(`Hello ${firstName} ${lastName}${age ? `, you are ${age} years old` : ''}`); +}); +``` + +```sh +$ ts-node app.ts greet John Doe +Hello John Doe + +$ ts-node app.ts greet John Doe 25 +Hello John Doe, you are 25 years old +``` + ## Flags Flags are another way to pass values to your command. Mostly these are optional, but they don't have to be. Parameters decorated with the `Flag` type can be passed via `--name value` or `--name=value`. @@ -118,6 +136,59 @@ $ ts-node app.ts test --remove delete? true ``` +### Object Flags + +You can use object literals as flags to group related options together: + +```typescript +import { Flag } from '@deepkit/app'; + +interface UserOptions { + name: string; + age?: number; + email?: string; +} + +new App().command('create-user', (options: UserOptions & Flag) => { + console.log('Creating user:', options); +}); +``` + +```sh +$ ts-node app.ts create-user --name "John Doe" --age 30 --email "john@example.com" +Creating user: { name: 'John Doe', age: 30, email: 'john@example.com' } +``` + +### Flag Prefixes + +When using multiple object flags, you can use prefixes to avoid naming conflicts: + +```typescript +interface DatabaseOptions { + host: string; + port?: number; +} + +interface CacheOptions { + host: string; + ttl?: number; +} + +new App().command('setup', ( + db: DatabaseOptions & Flag, + cache: CacheOptions & Flag<{ prefix: 'cache' }> +) => { + console.log('Database:', db); + console.log('Cache:', cache); +}); +``` + +```sh +$ ts-node app.ts setup --host "db.example.com" --cache.host "cache.example.com" --cache.ttl 3600 +Database: { host: 'db.example.com' } +Cache: { host: 'cache.example.com', ttl: 3600 } +``` + ### Multiple Flags To pass multiple values to the same flag, a flag can be marked as an array. @@ -284,6 +355,41 @@ Validation error in id: Number needs to be positive [positive] This additional validation, which is very easy to do, makes the command much more robust against wrong entries. See the chapter [Validation](../runtime-types/validation.md) for more information. +### Union Types + +Union types allow you to restrict values to a specific set of options: + +```typescript +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +new App().command('log', (message: string, level: LogLevel & Flag = 'info') => { + console.log(`[${level.toUpperCase()}] ${message}`); +}); +``` + +```sh +$ ts-node app.ts log "Hello World" --level debug +[DEBUG] Hello World + +$ ts-node app.ts log "Hello World" --level invalid +Invalid value for option --level: invalid. No valid union member found. Valid: 'debug' | 'info' | 'warn' | 'error' +``` + +### Date and Complex Types + +Deepkit automatically handles complex types like dates: + +```typescript +new App().command('schedule', (task: string, date: Date & Flag) => { + console.log(`Task "${task}" scheduled for ${date.toISOString()}`); +}); +``` + +```sh +$ ts-node app.ts schedule "Meeting" --date "2024-01-15T10:00:00Z" +Task "Meeting" scheduled for 2024-01-15T10:00:00.000Z +``` + ## Description To describe a flag or argument, use `@description` comment decorator. @@ -316,3 +422,35 @@ ARGUMENTS OPTIONS --remove Delete the user? ``` + +## Command Namespacing + +You can organize commands into namespaces using the colon syntax: + +```typescript +new App() + .command('user:create', (name: string) => { + console.log(`Creating user: ${name}`); + }) + .command('user:delete', (id: number) => { + console.log(`Deleting user: ${id}`); + }) + .command('user:list', () => { + console.log('Listing all users'); + }); +``` + +```sh +$ ts-node app.ts user +USAGE + $ ts-node app.ts user:[COMMAND] + +COMMANDS +user + user:create + user:delete + user:list + +$ ts-node app.ts user:create "John Doe" +Creating user: John Doe +``` diff --git a/website/src/pages/documentation/app/best-practices.md b/website/src/pages/documentation/app/best-practices.md new file mode 100644 index 000000000..a848be374 --- /dev/null +++ b/website/src/pages/documentation/app/best-practices.md @@ -0,0 +1,570 @@ +# Best Practices + +This guide covers best practices for building robust, maintainable, and performant Deepkit applications. + +## Project Structure + +Organize your project for clarity and maintainability: + +``` +my-app/ +├── src/ +│ ├── commands/ # CLI command controllers +│ │ ├── user.cli.ts +│ │ └── database.cli.ts +│ ├── services/ # Business logic services +│ │ ├── user.service.ts +│ │ └── email.service.ts +│ ├── modules/ # Feature modules +│ │ ├── user.module.ts +│ │ └── auth.module.ts +│ ├── config/ # Configuration classes +│ │ └── app.config.ts +│ └── types/ # Type definitions +│ └── user.types.ts +├── tests/ # Test files +│ ├── commands/ +│ ├── services/ +│ └── integration/ +├── app.ts # Application entry point +├── tsconfig.json +└── package.json +``` + +## Configuration Management + +### Use Type-Safe Configuration + +Always define configuration with proper types and validation: + +```typescript +import { MinLength, Minimum, Email } from '@deepkit/type'; + +export class AppConfig { + // Required fields + databaseUrl!: string & MinLength<10>; + + // Optional with defaults + port: number & Minimum<1000> = 3000; + debug: boolean = false; + + // Validated types + adminEmail?: string & Email; + + // Nested configuration + redis: { + host: string; + port: number; + } = { + host: 'localhost', + port: 6379 + }; +} +``` + +### Environment-Specific Configuration + +Use environment variables for deployment-specific values: + +```typescript +const app = new App({ + config: AppConfig +}) +.loadConfigFromEnv({ + prefix: 'MYAPP_', + envFilePath: ['.env.local', '.env'] +}) +.configure({ + // Override defaults programmatically + debug: process.env.NODE_ENV === 'development' +}); +``` + +## Service Design + +### Single Responsibility Principle + +Keep services focused on a single responsibility: + +```typescript +// Good: Focused service +export class UserRepository { + async findById(id: string): Promise { + // Database access logic only + } + + async save(user: User): Promise { + // Save logic only + } +} + +export class UserService { + constructor( + private userRepo: UserRepository, + private emailService: EmailService + ) {} + + async createUser(userData: CreateUserData): Promise { + // Business logic only + const user = new User(userData); + await this.userRepo.save(user); + await this.emailService.sendWelcomeEmail(user.email); + return user; + } +} +``` + +### Dependency Injection Best Practices + +Use constructor injection and interface segregation: + +```typescript +// Good: Interface segregation +interface EmailSender { + sendEmail(to: string, subject: string, body: string): Promise; +} + +interface UserNotifier { + notifyUserCreated(user: User): Promise; +} + +export class UserService { + constructor( + private userRepo: UserRepository, + private emailSender: EmailSender, + private notifier: UserNotifier + ) {} +} + +// Implementation +export class EmailService implements EmailSender, UserNotifier { + async sendEmail(to: string, subject: string, body: string): Promise { + // Implementation + } + + async notifyUserCreated(user: User): Promise { + await this.sendEmail(user.email, 'Welcome!', 'Welcome to our app'); + } +} +``` + +## Command Design + +### Use Descriptive Command Names + +Choose clear, action-oriented command names: + +```typescript +// Good: Clear command structure +app.command('user:create', createUserCommand); +app.command('user:delete', deleteUserCommand); +app.command('user:list', listUsersCommand); +app.command('database:migrate', migrateCommand); +app.command('database:seed', seedCommand); +``` + +### Validate Input Early + +Use TypeScript types and validation for robust commands: + +```typescript +import { Email, MinLength, Positive } from '@deepkit/type'; + +@cli.controller('user:create') +export class CreateUserCommand { + async execute( + /** @description User's email address */ + email: string & Email, + + /** @description User's full name */ + name: string & MinLength<2>, + + /** @description User's age */ + age: number & Positive, + + /** @description Send welcome email */ + sendWelcome: boolean & Flag = true + ) { + // Input is guaranteed to be valid here + const user = await this.userService.createUser({ + email, name, age + }); + + if (sendWelcome) { + await this.emailService.sendWelcomeEmail(email); + } + + console.log(`User created: ${user.id}`); + } +} +``` + +### Handle Errors Gracefully + +Provide meaningful error messages and appropriate exit codes: + +```typescript +@cli.controller('user:delete') +export class DeleteUserCommand { + async execute(id: string): Promise { + try { + const user = await this.userService.findById(id); + if (!user) { + console.error(`User with ID ${id} not found`); + return 1; // Exit code for "not found" + } + + await this.userService.delete(id); + console.log(`User ${id} deleted successfully`); + return 0; // Success + + } catch (error) { + console.error(`Failed to delete user: ${error.message}`); + return 2; // Exit code for "operation failed" + } + } +} +``` + +## Module Organization + +### Feature-Based Modules + +Organize modules around business features: + +```typescript +// user.module.ts +export class UserModule extends createModuleClass({ + providers: [ + UserService, + UserRepository, + UserValidator + ], + controllers: [ + CreateUserCommand, + DeleteUserCommand, + ListUsersCommand + ], + exports: [ + UserService // Export what other modules might need + ] +}) {} + +// auth.module.ts +export class AuthModule extends createModuleClass({ + imports: [new UserModule()], // Import user functionality + providers: [ + AuthService, + TokenService + ], + controllers: [ + LoginCommand, + LogoutCommand + ] +}) {} +``` + +### Configuration Inheritance + +Use module configuration for reusable components: + +```typescript +export class DatabaseConfig { + host: string = 'localhost'; + port: number = 5432; + database!: string; + username!: string; + password!: string; +} + +export class DatabaseModule extends createModuleClass({ + config: DatabaseConfig, + providers: [DatabaseService], + exports: [DatabaseService] +}) { + process() { + // Validate configuration + if (!this.config.database) { + throw new Error('Database name is required'); + } + } +} + +// Usage +const app = new App({ + imports: [ + new DatabaseModule({ + database: 'myapp', + username: 'user', + password: 'pass' + }) + ] +}); +``` + +## Error Handling + +### Global Error Handling + +Set up global error handlers for consistent error management: + +```typescript +import { onAppError } from '@deepkit/app'; + +const app = new App({ + providers: [ErrorReportingService] +}); + +app.listen(onAppError, async (event, errorReporting: ErrorReportingService, logger: Logger) => { + // Log the error + logger.error('Command failed:', { + command: event.command, + error: event.error.message, + stack: event.error.stack + }); + + // Report to external service + await errorReporting.reportError(event.error, { + command: event.command, + parameters: event.parameters + }); + + // Return appropriate exit code + if (event.error instanceof ValidationError) { + return 1; // User input error + } else if (event.error instanceof NetworkError) { + return 2; // Network/external service error + } else { + return 3; // Internal application error + } +}); +``` + +### Custom Error Types + +Create specific error types for better error handling: + +```typescript +export class UserNotFoundError extends Error { + constructor(userId: string) { + super(`User with ID ${userId} not found`); + this.name = 'UserNotFoundError'; + } +} + +export class ValidationError extends Error { + constructor(field: string, value: any) { + super(`Invalid value for ${field}: ${value}`); + this.name = 'ValidationError'; + } +} + +// Usage in services +export class UserService { + async findById(id: string): Promise { + const user = await this.userRepo.findById(id); + if (!user) { + throw new UserNotFoundError(id); + } + return user; + } +} +``` + +## Performance Optimization + +### Lazy Loading + +Use lazy loading for expensive services: + +```typescript +export class ExpensiveService { + private initialized = false; + + async initialize() { + if (this.initialized) return; + + // Expensive initialization + await this.loadLargeDataset(); + this.initialized = true; + } + + async doWork() { + await this.initialize(); + // Work with initialized data + } +} +``` + +### Scoped Services + +Use appropriate service scopes: + +```typescript +const app = new App({ + providers: [ + // Singleton: Shared across the entire application + DatabaseService, + + // Transient: New instance every time + { provide: RequestProcessor, scope: Scope.transient }, + + // CLI scoped: One instance per command execution + { provide: CommandContext, scope: 'cli' } + ] +}); +``` + +## Testing Strategy + +### Test Pyramid + +Follow the test pyramid principle: + +```typescript +// Unit tests (most) +test('UserService.createUser validates email', () => { + const service = new UserService(mockRepo, mockEmail); + expect(() => service.createUser({ email: 'invalid' })) + .toThrow('Invalid email'); +}); + +// Integration tests (some) +test('CreateUserCommand integration', async () => { + const testing = createTestingApp({ + controllers: [CreateUserCommand], + providers: [UserService, UserRepository] + }); + + const exitCode = await testing.app.execute([ + 'user:create', 'test@example.com', 'John Doe', '25' + ]); + + expect(exitCode).toBe(0); +}); + +// E2E tests (few) +test('Full user creation workflow', async () => { + // Test the entire application flow +}); +``` + +### Mock External Dependencies + +Mock external services in tests: + +```typescript +const mockEmailService = { + sendEmail: jest.fn().mockResolvedValue(true) +}; + +const testing = createTestingApp({ + providers: [ + UserService, + { provide: EmailService, useValue: mockEmailService } + ] +}); +``` + +## Security Considerations + +### Input Validation + +Always validate and sanitize input: + +```typescript +import { Email, MinLength, Pattern } from '@deepkit/type'; + +// Use type constraints for validation +type SafeString = string & MinLength<1> & Pattern<'^[a-zA-Z0-9\\s]+$'>; + +@cli.controller('user:update') +export class UpdateUserCommand { + async execute( + id: string, + name: SafeString, + email: string & Email + ) { + // Input is automatically validated + } +} +``` + +### Environment Variables + +Never commit secrets to version control: + +```typescript +export class AppConfig { + // Use environment variables for secrets + databasePassword!: string; // Set via MYAPP_DATABASE_PASSWORD + apiKey!: string; // Set via MYAPP_API_KEY + + // Public configuration can have defaults + port: number = 3000; + debug: boolean = false; +} +``` + +## Monitoring and Logging + +### Structured Logging + +Use structured logging for better observability: + +```typescript +export class UserService { + constructor(private logger: Logger) {} + + async createUser(userData: CreateUserData): Promise { + this.logger.log('Creating user', { + email: userData.email, + timestamp: new Date().toISOString() + }); + + try { + const user = await this.userRepo.save(new User(userData)); + + this.logger.log('User created successfully', { + userId: user.id, + email: user.email + }); + + return user; + } catch (error) { + this.logger.error('Failed to create user', { + email: userData.email, + error: error.message + }); + throw error; + } + } +} +``` + +### Health Checks + +Implement health checks for monitoring: + +```typescript +@cli.controller('health:check') +export class HealthCheckCommand { + constructor( + private db: DatabaseService, + private redis: RedisService + ) {} + + async execute(): Promise { + try { + await this.db.ping(); + await this.redis.ping(); + + console.log('All services healthy'); + return 0; + } catch (error) { + console.error('Health check failed:', error.message); + return 1; + } + } +} +``` diff --git a/website/src/pages/documentation/app/configuration.md b/website/src/pages/documentation/app/configuration.md index 42b5ce636..8dcb256dd 100644 --- a/website/src/pages/documentation/app/configuration.md +++ b/website/src/pages/documentation/app/configuration.md @@ -223,7 +223,7 @@ export class MyService { new MyService({title: 'Hello', host: '0.0.0.0'}); //or you can use type aliases -type MyServiceConfig = Pick; export class MyService { constructor(private config: MyServiceConfig) { } @@ -263,3 +263,230 @@ export class MyService { } } ``` + +## Advanced Configuration Patterns + +### Conditional Configuration + +Configure services based on configuration values: + +```typescript +class AppConfig { + environment: 'development' | 'production' = 'development'; + enableCache: boolean = false; + logLevel: 'debug' | 'info' | 'warn' | 'error' = 'info'; +} + +const app = new App({ + config: AppConfig +}) +.setup((module, config) => { + // Add development-only services + if (config.environment === 'development') { + module.addProvider(DevToolsService); + } + + // Configure logger based on log level + module.configureProvider(logger => { + logger.level = config.logLevel === 'debug' ? 5 : 3; + }); + + // Conditionally add cache service + if (config.enableCache) { + module.addProvider(CacheService); + } +}); +``` + +### Nested Configuration + +Organize complex configuration with nested objects: + +```typescript +class DatabaseConfig { + host: string = 'localhost'; + port: number = 5432; + username: string = 'user'; + password!: string; + ssl: { + enabled: boolean; + cert?: string; + key?: string; + } = { enabled: false }; +} + +class AppConfig { + database: DatabaseConfig = new DatabaseConfig(); + redis: { + host: string; + port: number; + } = { + host: 'localhost', + port: 6379 + }; +} +``` + +Environment variables for nested configuration: +```bash +# Nested object properties +APP_DATABASE_HOST=db.example.com +APP_DATABASE_PORT=5432 +APP_DATABASE_SSL_ENABLED=true +APP_REDIS_HOST=cache.example.com +``` + +### Configuration Validation + +Add custom validation to configuration: + +```typescript +import { MinLength, Minimum, Maximum, Email } from '@deepkit/type'; + +class AppConfig { + // String validation + appName: string & MinLength<3> = 'MyApp'; + + // Number validation + port: number & Minimum<1000> & Maximum<65535> = 3000; + + // Email validation + adminEmail?: string & Email; + + // Custom validation in process hook + databaseUrl!: string; +} + +class AppModule extends createModuleClass({ + config: AppConfig +}) { + process() { + // Custom validation logic + if (!this.config.databaseUrl.startsWith('postgresql://')) { + throw new Error('Database URL must be a PostgreSQL connection string'); + } + + if (this.config.port === 3000 && this.config.appName === 'MyApp') { + console.warn('Using default configuration values in production'); + } + } +} +``` + +## Configuration Loading Priority + +Configuration is loaded in the following order (later sources override earlier ones): + +1. Default values in configuration class +2. Module constructor parameters +3. Configuration loaders (in order they were added) +4. `app.configure()` calls + +```typescript +class Config { + port: number = 3000; // 1. Default value +} + +const app = new App({ + config: Config +}) +.loadConfigFromEnv() // 3. Environment variables +.configure({ port: 8080 }); // 4. Programmatic override + +// Final port value: 8080 (from configure call) +``` + +## Environment-Specific Configuration + +### Multiple Environment Files + +Load different configuration files based on environment: + +```typescript +const environment = process.env.NODE_ENV || 'development'; + +const app = new App({ + config: AppConfig +}) +.loadConfigFromEnv({ + envFilePath: [ + `.env.${environment}.local`, + `.env.${environment}`, + '.env.local', + '.env' + ] +}); +``` + +### Environment Variable Naming + +Customize environment variable naming: + +```typescript +app.loadConfigFromEnv({ + prefix: 'MYAPP_', + namingStrategy: (name: string) => { + // Custom naming strategy + return name.toUpperCase().replace(/([A-Z])/g, '_$1'); + } +}); +``` + +## Troubleshooting Configuration + +### Common Issues + +**Problem**: Configuration not loading from environment variables + +**Solution**: Check naming convention and prefix: +```bash +# Wrong +export PORT=3000 + +# Correct (with default APP_ prefix) +export APP_PORT=3000 +``` + +**Problem**: Type validation errors + +**Solution**: Ensure environment values can be converted: +```bash +# Wrong - cannot convert to number +export APP_PORT=abc + +# Correct +export APP_PORT=3000 +``` + +**Problem**: Required fields not provided + +**Solution**: Provide all required configuration: +```typescript +class Config { + databaseUrl!: string; // Required +} + +// Must provide via environment or configure() +export APP_DATABASE_URL="postgresql://localhost/mydb" +``` + +### Debug Configuration Loading + +Enable debug logging to see configuration loading: + +```typescript +import { Logger, ConsoleTransport } from '@deepkit/logger'; + +const app = new App({ + config: AppConfig, + providers: [ + { provide: Logger, useValue: new Logger([new ConsoleTransport()], 5) } + ] +}) +.loadConfigFromEnv(); + +// Check final configuration +app.command('debug-config', (config: AppConfig, logger: Logger) => { + logger.log('Final configuration:', config); +}); +``` diff --git a/website/src/pages/documentation/app/events.md b/website/src/pages/documentation/app/events.md index 985150dd2..3fd4a4971 100644 --- a/website/src/pages/documentation/app/events.md +++ b/website/src/pages/documentation/app/events.md @@ -223,6 +223,246 @@ new App({ | onAppError | When a command failed to execute | | onAppShutdown | When the application is about to shut down. | +## Practical Examples + +### User Management Events + +Create a complete user management system with events: + +```typescript +import { DataEventToken, BaseEvent } from '@deepkit/event'; + +// Define events +const UserCreated = new DataEventToken<{id: string, email: string}>('user.created'); +const UserDeleted = new DataEventToken<{id: string}>('user.deleted'); +const UserEmailChanged = new DataEventToken<{id: string, oldEmail: string, newEmail: string}>('user.email.changed'); + +// Services that emit events +class UserService { + constructor(private eventDispatcher: EventDispatcher) {} + + async createUser(email: string): Promise { + const id = generateId(); + // ... create user logic + + await this.eventDispatcher.dispatch(UserCreated, { id, email }); + return id; + } + + async deleteUser(id: string): Promise { + // ... delete user logic + await this.eventDispatcher.dispatch(UserDeleted, { id }); + } +} + +// Event listeners +class EmailService { + @eventDispatcher.listen(UserCreated) + async sendWelcomeEmail(event: typeof UserCreated.event) { + console.log(`Sending welcome email to ${event.data.email}`); + // Send email logic + } + + @eventDispatcher.listen(UserDeleted) + async sendGoodbyeEmail(event: typeof UserDeleted.event) { + console.log(`User ${event.data.id} deleted, sending goodbye email`); + } +} + +class AuditService { + @eventDispatcher.listen(UserCreated, 1) // Higher priority (lower number) + logUserCreation(event: typeof UserCreated.event) { + console.log(`AUDIT: User created - ID: ${event.data.id}, Email: ${event.data.email}`); + } + + @eventDispatcher.listen(UserDeleted, 1) + logUserDeletion(event: typeof UserDeleted.event) { + console.log(`AUDIT: User deleted - ID: ${event.data.id}`); + } +} +``` + +### Command Events with Data + +Create events that carry command execution data: + +```typescript +class CommandExecutionEvent extends BaseEvent { + command: string = ''; + args: string[] = []; + startTime: Date = new Date(); + endTime?: Date; + exitCode?: number; + error?: Error; +} + +const CommandStarted = new EventToken('command.started'); +const CommandCompleted = new EventToken('command.completed'); +const CommandFailed = new EventToken('command.failed'); + +// Metrics service that tracks command performance +class MetricsService { + private commandMetrics = new Map(); + + @eventDispatcher.listen(CommandStarted) + onCommandStarted(event: CommandExecutionEvent) { + console.log(`Command started: ${event.command} with args: ${event.args.join(' ')}`); + } + + @eventDispatcher.listen(CommandCompleted) + onCommandCompleted(event: CommandExecutionEvent) { + if (event.endTime && event.startTime) { + const duration = event.endTime.getTime() - event.startTime.getTime(); + const metrics = this.commandMetrics.get(event.command) || {count: 0, totalTime: 0}; + metrics.count++; + metrics.totalTime += duration; + this.commandMetrics.set(event.command, metrics); + + console.log(`Command ${event.command} completed in ${duration}ms`); + } + } + + @eventDispatcher.listen(CommandFailed) + onCommandFailed(event: CommandExecutionEvent) { + console.error(`Command ${event.command} failed:`, event.error?.message); + } + + getMetrics() { + return Array.from(this.commandMetrics.entries()).map(([command, metrics]) => ({ + command, + count: metrics.count, + averageTime: metrics.totalTime / metrics.count + })); + } +} +``` + +### Event-Driven Workflow + +Create complex workflows using events: + +```typescript +// Workflow events +const OrderCreated = new DataEventToken<{orderId: string, userId: string, amount: number}>('order.created'); +const PaymentProcessed = new DataEventToken<{orderId: string, paymentId: string}>('payment.processed'); +const InventoryReserved = new DataEventToken<{orderId: string, items: string[]}>('inventory.reserved'); +const OrderFulfilled = new DataEventToken<{orderId: string}>('order.fulfilled'); + +class OrderWorkflow { + @eventDispatcher.listen(OrderCreated) + async processPayment(event: typeof OrderCreated.event, paymentService: PaymentService) { + try { + const paymentId = await paymentService.processPayment(event.data.amount); + await this.eventDispatcher.dispatch(PaymentProcessed, { + orderId: event.data.orderId, + paymentId + }); + } catch (error) { + console.error(`Payment failed for order ${event.data.orderId}:`, error); + } + } + + @eventDispatcher.listen(PaymentProcessed) + async reserveInventory(event: typeof PaymentProcessed.event, inventoryService: InventoryService) { + const items = await inventoryService.reserveItems(event.data.orderId); + await this.eventDispatcher.dispatch(InventoryReserved, { + orderId: event.data.orderId, + items + }); + } + + @eventDispatcher.listen(InventoryReserved) + async fulfillOrder(event: typeof InventoryReserved.event) { + // Fulfill order logic + await this.eventDispatcher.dispatch(OrderFulfilled, { + orderId: event.data.orderId + }); + } +} +``` + +### Error Handling in Events + +Handle errors gracefully in event listeners: + +```typescript +class RobustEventListener { + @eventDispatcher.listen(UserCreated) + async handleUserCreated(event: typeof UserCreated.event, logger: Logger) { + try { + // Risky operation + await this.sendWelcomeEmail(event.data.email); + } catch (error) { + logger.error('Failed to send welcome email:', error); + // Don't re-throw - let other listeners continue + } + } + + @eventDispatcher.listen(UserCreated) + async criticalUserCreatedHandler(event: typeof UserCreated.event) { + try { + await this.createUserProfile(event.data.id); + } catch (error) { + // This is critical - stop event propagation + event.stop(); + throw error; + } + } +} +``` + +## Testing Events + +Test event-driven code effectively: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +test('user creation triggers welcome email', async () => { + const mockEmailService = { + sendWelcomeEmail: jest.fn() + }; + + const testing = createTestingApp({ + providers: [ + UserService, + { provide: EmailService, useValue: mockEmailService } + ], + listeners: [EmailService] + }); + + const userService = testing.app.get(UserService); + await userService.createUser('test@example.com'); + + expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + email: 'test@example.com' + }) + }) + ); +}); + +test('event propagation can be stopped', async () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + + const testing = createTestingApp({}); + + testing.app.listen(UserCreated, (event) => { + listener1(); + event.stop(); // Stop propagation + }, 0); + + testing.app.listen(UserCreated, listener2, 1); // Lower priority + + await testing.app.dispatch(UserCreated, { id: '1', email: 'test@example.com' }); + + expect(listener1).toHaveBeenCalled(); + expect(listener2).not.toHaveBeenCalled(); // Should not be called due to stop() +}); +``` + ## Low Level API Below is an example of the low-level API from @deepkit/event. When using the Deepkit App, event listeners are not registered directly via the EventDispatcher, but rather through modules. But you can still use the low-level API if you want to. diff --git a/website/src/pages/documentation/app/lifecycle.md b/website/src/pages/documentation/app/lifecycle.md new file mode 100644 index 000000000..c1f31d9c2 --- /dev/null +++ b/website/src/pages/documentation/app/lifecycle.md @@ -0,0 +1,352 @@ +# Application Lifecycle + +Understanding the Deepkit App lifecycle is crucial for building robust applications. This chapter covers the startup process, lifecycle events, error handling, and shutdown procedures. + +## Application Startup Process + +When you call `app.run()` or `app.execute()`, Deepkit follows a specific sequence: + +1. **Module Discovery**: Find all modules starting from the root module +2. **Configuration Loading**: Load and validate configuration from all sources +3. **Service Container Building**: Build the dependency injection container +4. **Bootstrap**: Execute bootstrap hooks and initialize services +5. **Command Execution**: Parse arguments and execute the requested command +6. **Shutdown**: Clean up resources and exit + +### Detailed Startup Sequence + +```typescript +import { App, onAppExecute, onAppExecuted, onAppError, onAppShutdown } from '@deepkit/app'; + +const app = new App({ + providers: [MyService] +}); + +// These events fire during the lifecycle +app.listen(onAppExecute, (event) => { + console.log('About to execute command:', event.command); +}); + +app.listen(onAppExecuted, (event) => { + console.log('Command executed with exit code:', event.exitCode); +}); + +app.listen(onAppError, (event) => { + console.log('Command failed with error:', event.error.message); +}); + +app.listen(onAppShutdown, () => { + console.log('Application is shutting down'); +}); + +app.run(); +``` + +## Module Processing Order + +Modules are processed in a specific order to ensure dependencies are resolved correctly: + +```typescript +import { createModuleClass } from '@deepkit/app'; + +class DatabaseModule extends createModuleClass({ + providers: [DatabaseService] +}) { + process() { + console.log('1. DatabaseModule.process()'); + } + + postProcess() { + console.log('4. DatabaseModule.postProcess()'); + } +} + +class UserModule extends createModuleClass({ + imports: [new DatabaseModule()], + providers: [UserService] +}) { + process() { + console.log('2. UserModule.process()'); + } + + postProcess() { + console.log('5. UserModule.postProcess()'); + } +} + +const app = new App({ + imports: [new UserModule()] +}); + +// Processing order: +// 1. DatabaseModule.process() +// 2. UserModule.process() +// 3. Service container built +// 4. DatabaseModule.postProcess() +// 5. UserModule.postProcess() +``` + +## Configuration Lifecycle + +Configuration is loaded and validated before services are instantiated: + +```typescript +class AppConfig { + databaseUrl: string = 'sqlite://memory'; + debug: boolean = false; +} + +class DatabaseService { + constructor(private databaseUrl: AppConfig['databaseUrl']) { + // Configuration is guaranteed to be loaded and validated here + console.log('Connecting to:', this.databaseUrl); + } +} + +const app = new App({ + config: AppConfig, + providers: [DatabaseService] +}) +.loadConfigFromEnv() // Loads before service instantiation +.configure({ debug: true }); // Also loads before service instantiation +``` + +## Service Instantiation + +Services are instantiated lazily when first requested: + +```typescript +class ExpensiveService { + constructor() { + console.log('ExpensiveService instantiated'); + // This only runs when the service is first requested + } +} + +class UserService { + constructor(private expensiveService: ExpensiveService) { + // ExpensiveService is instantiated here when UserService is created + } +} + +const app = new App({ + providers: [ExpensiveService, UserService] +}); + +// Services are not instantiated yet +console.log('App created'); + +// UserService (and ExpensiveService) instantiated here +const userService = app.get(UserService); +``` + +## Scoped Services + +Different scopes have different lifecycles: + +```typescript +import { Scope } from '@deepkit/injector'; + +class SingletonService { + // Default scope - one instance per module +} + +class TransientService { + // New instance every time it's requested +} + +class CliService { + // One instance per CLI command execution +} + +const app = new App({ + providers: [ + SingletonService, // Default: singleton + { provide: TransientService, scope: Scope.transient }, + { provide: CliService, scope: 'cli' } + ] +}); +``` + +## Error Handling + +Handle errors at different stages of the lifecycle: + +```typescript +import { onAppError } from '@deepkit/app'; + +class ErrorProneService { + constructor() { + // Errors here are caught during service instantiation + throw new Error('Service initialization failed'); + } +} + +const app = new App({ + providers: [ErrorProneService] +}); + +// Global error handler +app.listen(onAppError, (event, logger: Logger) => { + logger.error('Application error:', event.error); + + // You can modify the exit code + if (event.error.message.includes('critical')) { + return 1; // Exit with error code 1 + } +}); + +// Command-specific error handling +app.command('risky', () => { + throw new Error('Something went wrong'); +}); + +app.run(); +``` + +## Graceful Shutdown + +Handle cleanup when the application shuts down: + +```typescript +import { onAppShutdown } from '@deepkit/app'; + +class DatabaseService { + private connection: any; + + constructor() { + this.connection = createConnection(); + } + + async close() { + await this.connection.close(); + } +} + +const app = new App({ + providers: [DatabaseService] +}); + +// Register shutdown handler +app.listen(onAppShutdown, async (event, db: DatabaseService) => { + console.log('Closing database connection...'); + await db.close(); +}); + +// Handle process signals +if (typeof process !== 'undefined') { + process.on('SIGINT', async () => { + console.log('Received SIGINT, shutting down gracefully...'); + await app.dispatch(onAppShutdown); + process.exit(0); + }); +} +``` + +## Bootstrap Hooks + +Use bootstrap hooks to initialize services after the container is built: + +```typescript +class CacheService { + private cache = new Map(); + + async initialize() { + // Load initial cache data + console.log('Initializing cache...'); + } +} + +class AppModule extends createModuleClass({ + providers: [CacheService] +}) { + async bootstrap(cache: CacheService) { + // Called after all modules are processed + await cache.initialize(); + } +} + +const app = App.fromModule(new AppModule()); +``` + +## Setup Hooks + +Use setup hooks to configure the application before services are built: + +```typescript +const app = new App({ + providers: [DatabaseService] +}) +.setup((module, config) => { + // Called after configuration is loaded but before services are built + if (config.debug) { + module.addProvider(DebugService); + } + + // Configure existing providers + module.configureProvider(db => { + db.setConnectionPool(config.maxConnections); + }); +}); +``` + +## Command Execution Lifecycle + +Each command execution follows its own lifecycle: + +```typescript +app.command('deploy', async (environment: string, logger: Logger) => { + logger.log('Starting deployment to', environment); + + try { + // Command logic here + await deployToEnvironment(environment); + logger.log('Deployment successful'); + return 0; // Success exit code + } catch (error) { + logger.error('Deployment failed:', error); + return 1; // Error exit code + } +}); +``` + +## Lifecycle Events Summary + +| Event | When | Use Case | +|-------|------|----------| +| `onAppExecute` | Before command execution | Logging, setup | +| `onAppExecuted` | After successful command | Cleanup, metrics | +| `onAppError` | When command fails | Error handling, logging | +| `onAppShutdown` | Application shutdown | Resource cleanup | + +## Best Practices + +1. **Use lifecycle events for cross-cutting concerns**: Logging, metrics, cleanup +2. **Handle errors gracefully**: Provide meaningful error messages and appropriate exit codes +3. **Clean up resources**: Use shutdown handlers to close connections and free resources +4. **Lazy initialization**: Services are instantiated when needed, not at startup +5. **Configuration validation**: Validate configuration early in the lifecycle +6. **Scoped services appropriately**: Use the right scope for your service's lifecycle needs + +```typescript +// Good: Proper lifecycle management +class FileService { + private fileHandle?: FileHandle; + + async openFile(path: string) { + this.fileHandle = await open(path); + } + + async close() { + if (this.fileHandle) { + await this.fileHandle.close(); + } + } +} + +const app = new App({ providers: [FileService] }); + +app.listen(onAppShutdown, async (event, fileService: FileService) => { + await fileService.close(); +}); +``` diff --git a/website/src/pages/documentation/app/modules.md b/website/src/pages/documentation/app/modules.md index 2412949a5..9fed95507 100644 --- a/website/src/pages/documentation/app/modules.md +++ b/website/src/pages/documentation/app/modules.md @@ -512,6 +512,308 @@ new App({ }).run(); ``` +## Testing Modules + +Test modules in isolation or as part of larger applications: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +test('DatabaseModule provides DatabaseService', () => { + const testing = createTestingApp({ + imports: [new DatabaseModule({ host: 'localhost' })] + }); + + const dbService = testing.app.get(DatabaseService); + expect(dbService).toBeInstanceOf(DatabaseService); +}); + +test('UserModule integrates with DatabaseModule', () => { + const testing = createTestingApp({ + imports: [new UserModule()] + }); + + const userService = testing.app.get(UserService); + const dbService = testing.app.get(DatabaseService); + + expect(userService).toBeInstanceOf(UserService); + expect(dbService).toBeInstanceOf(DatabaseService); +}); + +test('Module configuration validation', () => { + expect(() => { + new DatabaseModule({ host: '' }); // Invalid empty host + }).toThrow('Host cannot be empty'); +}); +``` + +## Module Composition Patterns + +### Plugin Architecture + +Create a plugin system using modules: + +```typescript +interface Plugin { + name: string; + version: string; + initialize(): void; +} + +class PluginModule extends createModuleClass({}) { + constructor(private plugin: Plugin) { + super(); + } + + process() { + this.addProvider({ provide: this.plugin.name, useValue: this.plugin }); + this.plugin.initialize(); + } +} + +// Usage +const app = new App({ + imports: [ + new PluginModule(new EmailPlugin()), + new PluginModule(new CachePlugin()), + new PluginModule(new MetricsPlugin()) + ] +}); +``` + +### Feature Flags + +Use modules to implement feature flags: + +```typescript +class FeatureConfig { + enableNewUI: boolean = false; + enableBetaFeatures: boolean = false; +} + +class FeatureModule extends createModuleClass({ + config: FeatureConfig +}) { + process() { + if (this.config.enableNewUI) { + this.addProvider(NewUIService); + this.addController(NewUIController); + } + + if (this.config.enableBetaFeatures) { + this.addProvider(BetaFeatureService); + } + } +} +``` + +### Environment-Specific Modules + +Load different modules based on environment: + +```typescript +class AppModule extends createModuleClass({}) { + process() { + const environment = process.env.NODE_ENV || 'development'; + + // Always load core modules + this.addImport(new DatabaseModule()); + this.addImport(new UserModule()); + + // Environment-specific modules + if (environment === 'development') { + this.addImport(new DevToolsModule()); + this.addImport(new MockServicesModule()); + } else if (environment === 'production') { + this.addImport(new MonitoringModule()); + this.addImport(new CacheModule()); + } + + if (environment === 'test') { + this.addImport(new TestUtilitiesModule()); + } + } +} +``` + +## Advanced Module Hooks + +### Dynamic Provider Registration + +Register providers dynamically based on runtime conditions: + +```typescript +class DynamicModule extends createModuleClass({}) { + processProvider(module: AppModule, token: Token, provider: ProviderWithScope) { + // Automatically register all services that implement a specific interface + if (isClass(token) && implementsInterface(token, 'EventHandler')) { + this.eventHandlers.push(token); + } + + // Add logging to all services in development + if (process.env.NODE_ENV === 'development' && isClass(token)) { + this.wrapWithLogging(provider); + } + } + + postProcess() { + // Register all found event handlers + for (const handler of this.eventHandlers) { + this.addProvider(handler); + } + } +} +``` + +### Cross-Module Communication + +Enable modules to communicate with each other: + +```typescript +class ModuleRegistry { + private modules = new Map>(); + + register(name: string, module: AppModule) { + this.modules.set(name, module); + } + + get(name: string): AppModule | undefined { + return this.modules.get(name); + } +} + +class CommunicatingModule extends createModuleClass({ + name: 'communicating' +}) { + process() { + // Register self in registry + const registry = this.getImportedModuleByClass(RegistryModule) + .get(ModuleRegistry); + registry.register(this.name, this); + + // Find and configure other modules + const userModule = registry.get('user'); + if (userModule) { + userModule.configure({ enableNotifications: true }); + } + } +} +``` + +## Module Best Practices + +### 1. Single Responsibility + +Each module should have a clear, single responsibility: + +```typescript +// Good: Focused on user management +class UserModule extends createModuleClass({ + providers: [UserService, UserRepository, UserValidator], + controllers: [UserController], + exports: [UserService] +}) {} + +// Bad: Too many responsibilities +class EverythingModule extends createModuleClass({ + providers: [UserService, EmailService, PaymentService, LoggingService], + // ... too many unrelated services +}) {} +``` + +### 2. Clear Dependencies + +Make module dependencies explicit: + +```typescript +class UserModule extends createModuleClass({ + imports: [ + new DatabaseModule(), // Explicit dependency + new EmailModule() // Another explicit dependency + ], + providers: [UserService] +}) { + process() { + // Validate dependencies are available + if (!this.getImportedModuleByClass(DatabaseModule)) { + throw new Error('UserModule requires DatabaseModule'); + } + } +} +``` + +### 3. Configuration Validation + +Validate module configuration early: + +```typescript +class ApiConfig { + baseUrl!: string; + timeout: number = 5000; + retries: number = 3; +} + +class ApiModule extends createModuleClass({ + config: ApiConfig +}) { + process() { + // Validate configuration + if (!this.config.baseUrl) { + throw new Error('API base URL is required'); + } + + if (!this.config.baseUrl.startsWith('http')) { + throw new Error('API base URL must be a valid HTTP URL'); + } + + if (this.config.timeout < 1000) { + console.warn('API timeout is very low, consider increasing it'); + } + } +} +``` + +### 4. Graceful Degradation + +Handle missing optional dependencies gracefully: + +```typescript +class NotificationModule extends createModuleClass({ + providers: [NotificationService] +}) { + process() { + // Optional email integration + try { + const emailModule = this.getImportedModuleByClass(EmailModule); + this.configureProvider(service => { + service.setEmailService(emailModule.get(EmailService)); + }); + } catch { + console.log('Email module not available, notifications will use console only'); + } + } +} +``` + ## Injector Context The InjectorContext is the dependency injection container. It allows you to request/instantiate services from your own or other modules. This is necessary if for example you have stored a controller in `processControllers` and want to correctly instantiate them. + +```typescript +class ControllerRegistry { + private controllers = new Map, controller: ClassType}>(); + + register(name: string, module: AppModule, controller: ClassType) { + this.controllers.set(name, { module, controller }); + } + + instantiate(name: string, injectorContext: InjectorContext) { + const entry = this.controllers.get(name); + if (!entry) throw new Error(`Controller ${name} not found`); + + // Get the correct injector for the module + const injector = injectorContext.getInjector(entry.module); + return injector.get(entry.controller); + } +} +``` diff --git a/website/src/pages/documentation/app/testing.md b/website/src/pages/documentation/app/testing.md new file mode 100644 index 000000000..6813324c6 --- /dev/null +++ b/website/src/pages/documentation/app/testing.md @@ -0,0 +1,337 @@ +# Testing + +Testing is a crucial part of building reliable applications. Deepkit App provides excellent testing capabilities through its dependency injection system and testing utilities. This chapter covers how to test your CLI commands, services, and modules effectively. + +## Testing Setup + +First, make sure you have Jest (or your preferred testing framework) installed: + +```bash +npm install --save-dev jest @types/jest ts-jest +``` + +Configure Jest in your `package.json`: + +```json +{ + "scripts": { + "test": "jest" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testMatch": ["**/*.spec.ts"] + } +} +``` + +## Testing Services + +The simplest way to test services is to instantiate them directly: + +```typescript +import { Logger, MemoryLoggerTransport } from '@deepkit/logger'; + +class UserService { + constructor(private logger: Logger) {} + + createUser(name: string): boolean { + this.logger.log('Creating user:', name); + return true; + } +} + +test('UserService creates user', () => { + const memoryLogger = new MemoryLoggerTransport(); + const logger = new Logger([memoryLogger]); + const service = new UserService(logger); + + const result = service.createUser('John'); + + expect(result).toBe(true); + expect(memoryLogger.messages[0]).toMatchObject({ + message: 'Creating user: John' + }); +}); +``` + +## Testing with Dependency Injection + +For more complex scenarios, use the `createTestingApp` function from `@deepkit/framework`: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +class DatabaseService { + users: string[] = []; + + addUser(name: string) { + this.users.push(name); + } + + getUsers() { + return this.users; + } +} + +class UserService { + constructor(private db: DatabaseService) {} + + createUser(name: string) { + this.db.addUser(name); + return `User ${name} created`; + } +} + +test('UserService with DI', () => { + const testing = createTestingApp({ + providers: [DatabaseService, UserService] + }); + + const userService = testing.app.get(UserService); + const result = userService.createUser('Alice'); + + expect(result).toBe('User Alice created'); + + const dbService = testing.app.get(DatabaseService); + expect(dbService.getUsers()).toEqual(['Alice']); +}); +``` + +## Testing CLI Commands + +### Functional Commands + +Test functional commands by calling them directly through the app: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +test('hello command', async () => { + const testing = createTestingApp({}); + + testing.app.command('hello', (name: string) => { + return `Hello ${name}!`; + }); + + const result = await testing.app.execute(['hello', 'World']); + expect(result).toBe(0); // exit code +}); +``` + +### Class-based Commands + +For class-based commands, test them through the DI container: + +```typescript +import { cli } from '@deepkit/app'; + +@cli.controller('greet') +class GreetCommand { + constructor(private userService: UserService) {} + + async execute(name: string): Promise { + return this.userService.createUser(name); + } +} + +test('GreetCommand', async () => { + const testing = createTestingApp({ + controllers: [GreetCommand], + providers: [DatabaseService, UserService] + }); + + await testing.app.execute(['greet', 'Bob']); + + const dbService = testing.app.get(DatabaseService); + expect(dbService.getUsers()).toContain('Bob'); +}); +``` + +## Mocking Dependencies + +Use Jest mocks or custom providers to mock dependencies: + +```typescript +class EmailService { + sendEmail(to: string, subject: string): boolean { + // In real implementation, this would send an email + return true; + } +} + +class NotificationService { + constructor(private emailService: EmailService) {} + + notifyUser(email: string, message: string): boolean { + return this.emailService.sendEmail(email, message); + } +} + +test('NotificationService with mock', () => { + const mockEmailService = { + sendEmail: jest.fn().mockReturnValue(true) + }; + + const testing = createTestingApp({ + providers: [ + NotificationService, + { provide: EmailService, useValue: mockEmailService } + ] + }); + + const notificationService = testing.app.get(NotificationService); + const result = notificationService.notifyUser('test@example.com', 'Hello'); + + expect(result).toBe(true); + expect(mockEmailService.sendEmail).toHaveBeenCalledWith('test@example.com', 'Hello'); +}); +``` + +## Testing Modules + +Test modules by importing them into a test app: + +```typescript +import { createModuleClass } from '@deepkit/app'; + +class ModuleConfig { + apiKey: string = 'default-key'; +} + +class ApiService { + constructor(private apiKey: ModuleConfig['apiKey']) {} + + getApiKey() { + return this.apiKey; + } +} + +class ApiModule extends createModuleClass({ + config: ModuleConfig, + providers: [ApiService], + exports: [ApiService] +}) {} + +test('ApiModule configuration', () => { + const testing = createTestingApp({ + imports: [new ApiModule({ apiKey: 'test-key' })] + }); + + const apiService = testing.app.get(ApiService); + expect(apiService.getApiKey()).toBe('test-key'); +}); +``` + +## Testing Configuration + +Test configuration loading and validation: + +```typescript +import { MinLength } from '@deepkit/type'; + +class AppConfig { + name: string & MinLength<3> = 'MyApp'; + port: number = 3000; +} + +test('configuration validation', () => { + expect(() => { + createTestingApp({ + config: AppConfig + }, { name: 'AB' }); // Too short, should fail validation + }).toThrow(); +}); + +test('valid configuration', () => { + const testing = createTestingApp({ + config: AppConfig + }); + + testing.app.configure({ name: 'TestApp', port: 8080 }); + + const config = testing.app.get(AppConfig); + expect(config.name).toBe('TestApp'); + expect(config.port).toBe(8080); +}); +``` + +## Testing Event Listeners + +Test event listeners by dispatching events: + +```typescript +import { EventToken } from '@deepkit/event'; + +const UserCreated = new EventToken('user.created'); + +class UserEventListener { + lastCreatedUser?: string; + + @eventDispatcher.listen(UserCreated) + onUserCreated(event: typeof UserCreated.event) { + this.lastCreatedUser = event.data; + } +} + +test('event listener', async () => { + const testing = createTestingApp({ + listeners: [UserEventListener] + }); + + await testing.app.dispatch(UserCreated, 'John'); + + const listener = testing.app.get(UserEventListener); + expect(listener.lastCreatedUser).toBe('John'); +}); +``` + +## Integration Testing + +For integration tests, you can test the entire command execution: + +```typescript +test('full command integration', async () => { + const testing = createTestingApp({ + providers: [DatabaseService, UserService], + controllers: [GreetCommand] + }); + + // Test command execution with arguments + const exitCode = await testing.app.execute(['greet', 'Integration Test']); + expect(exitCode).toBe(0); + + // Verify side effects + const dbService = testing.app.get(DatabaseService); + expect(dbService.getUsers()).toContain('Integration Test'); +}); +``` + +## Best Practices + +1. **Isolate tests**: Each test should be independent and not rely on state from other tests. + +2. **Use dependency injection**: Leverage DI to inject mocks and test doubles. + +3. **Test behavior, not implementation**: Focus on what your code does, not how it does it. + +4. **Use descriptive test names**: Make it clear what each test is verifying. + +5. **Test edge cases**: Include tests for error conditions and boundary values. + +6. **Keep tests simple**: Each test should verify one specific behavior. + +```typescript +// Good: Descriptive name and single responsibility +test('UserService throws error when creating user with empty name', () => { + const service = new UserService(mockLogger); + expect(() => service.createUser('')).toThrow('Name cannot be empty'); +}); + +// Good: Testing the happy path +test('UserService successfully creates user with valid name', () => { + const service = new UserService(mockLogger); + const result = service.createUser('John'); + expect(result).toBe(true); +}); +``` diff --git a/website/src/pages/documentation/app/troubleshooting.md b/website/src/pages/documentation/app/troubleshooting.md new file mode 100644 index 000000000..7e18d6c36 --- /dev/null +++ b/website/src/pages/documentation/app/troubleshooting.md @@ -0,0 +1,457 @@ +# Troubleshooting + +This guide helps you diagnose and fix common issues when working with Deepkit App. + +## Common Issues + +### Type Compiler Issues + +**Problem**: Runtime types not working, getting `undefined` for type information. + +**Symptoms**: +```typescript +// This doesn't work as expected +app.command('test', (name: string) => { + // Type information is lost at runtime +}); +``` + +**Solution**: Ensure the Deepkit type compiler is properly installed and configured. + +1. Install the type compiler: +```bash +npm install @deepkit/type-compiler +./node_modules/.bin/deepkit-type-install +``` + +2. Configure `tsconfig.json`: +```json +{ + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "reflection": true +} +``` + +3. Verify installation: +```bash +# Check if the compiler is installed +ls node_modules/typescript/lib/deepkit* +``` + +### Dependency Injection Errors + +**Problem**: `Service 'X' not found` or circular dependency errors. + +**Symptoms**: +``` +Error: Service 'UserService' not found. Make sure it's provided in the module. +``` + +**Solutions**: + +1. **Missing Provider**: Ensure the service is registered: +```typescript +// Wrong +const app = new App({ + controllers: [UserController] // UserService not provided +}); + +// Correct +const app = new App({ + controllers: [UserController], + providers: [UserService] // Add the service +}); +``` + +2. **Circular Dependencies**: Refactor to break the cycle: +```typescript +// Wrong - circular dependency +class UserService { + constructor(private orderService: OrderService) {} +} + +class OrderService { + constructor(private userService: UserService) {} +} + +// Correct - use a shared service or event system +class UserService { + constructor(private eventDispatcher: EventDispatcher) {} + + createUser(userData: any) { + const user = new User(userData); + this.eventDispatcher.dispatch(UserCreated, user); + return user; + } +} + +class OrderService { + @eventDispatcher.listen(UserCreated) + onUserCreated(event: any) { + // Handle user creation + } +} +``` + +3. **Wrong Module Scope**: Ensure services are exported from modules: +```typescript +// Wrong +export class MyModule extends createModuleClass({ + providers: [MyService] // Not exported +}) {} + +// Correct +export class MyModule extends createModuleClass({ + providers: [MyService], + exports: [MyService] // Export for other modules +}) {} +``` + +### Configuration Issues + +**Problem**: Configuration not loading or validation errors. + +**Symptoms**: +``` +ConfigurationValidationError: Invalid value for 'port': abc. Cannot convert abc to number +``` + +**Solutions**: + +1. **Environment Variable Naming**: Check the naming convention: +```bash +# Wrong +export PORT=3000 + +# Correct (with default APP_ prefix) +export APP_PORT=3000 + +# Or configure custom prefix +app.loadConfigFromEnv({ prefix: 'MYAPP_' }); +export MYAPP_PORT=3000 +``` + +2. **Type Validation**: Ensure environment values match expected types: +```typescript +class Config { + port: number = 3000; // Expects number + debug: boolean = false; // Expects boolean +} + +// Environment variables are strings, so: +export APP_PORT=3000 # ✓ Can be converted to number +export APP_DEBUG=true # ✓ Can be converted to boolean +export APP_DEBUG=1 # ✓ Also works for boolean +``` + +3. **Missing Required Fields**: Provide required configuration: +```typescript +class Config { + databaseUrl!: string; // Required field +} + +// Must provide via environment or configure() +export APP_DATABASE_URL="sqlite://memory" +# or +app.configure({ databaseUrl: 'sqlite://memory' }); +``` + +### Command Line Parsing Issues + +**Problem**: Arguments not parsed correctly or validation errors. + +**Symptoms**: +``` +Invalid value for argument name: undefined +RequiredArgsError: Missing 1 required arg: name +``` + +**Solutions**: + +1. **Argument Order**: Ensure arguments are in the correct order: +```typescript +// Command definition +app.command('greet', (firstName: string, lastName: string) => { + console.log(`Hello ${firstName} ${lastName}`); +}); + +// Correct usage +$ ts-node app.ts greet John Doe + +// Wrong - arguments in wrong order +$ ts-node app.ts greet Doe John +``` + +2. **Optional vs Required**: Use proper TypeScript syntax: +```typescript +// Wrong - all required +app.command('greet', (name: string, age: number) => {}); + +// Correct - age is optional +app.command('greet', (name: string, age?: number) => {}); +// or with default +app.command('greet', (name: string, age: number = 25) => {}); +``` + +3. **Flag vs Argument**: Understand the difference: +```typescript +// Arguments (positional) +app.command('greet', (name: string) => {}); +// Usage: ts-node app.ts greet John + +// Flags (named) +app.command('greet', (name: string & Flag) => {}); +// Usage: ts-node app.ts greet --name John +``` + +### Module Loading Issues + +**Problem**: Modules not loading or processing in wrong order. + +**Symptoms**: +``` +Error: Cannot find module './my-module' +Module 'X' processed before its dependency 'Y' +``` + +**Solutions**: + +1. **Import Paths**: Use correct relative or absolute paths: +```typescript +// Wrong +import { MyModule } from 'my-module'; + +// Correct +import { MyModule } from './modules/my-module'; +// or +import { MyModule } from '../shared/my-module'; +``` + +2. **Module Dependencies**: Ensure proper import order: +```typescript +// Wrong - UserModule depends on DatabaseModule but imports it after +class UserModule extends createModuleClass({ + providers: [UserService] // UserService needs DatabaseService +}) { + imports = [new DatabaseModule()]; // Too late +} + +// Correct - import dependencies first +class UserModule extends createModuleClass({ + imports: [new DatabaseModule()], // Import first + providers: [UserService] +}) {} +``` + +### Performance Issues + +**Problem**: Slow startup or command execution. + +**Symptoms**: +- Long delays before commands execute +- High memory usage +- Slow dependency injection + +**Solutions**: + +1. **Lazy Loading**: Avoid expensive operations in constructors: +```typescript +// Wrong - expensive operation in constructor +class ExpensiveService { + constructor() { + this.loadLargeDataset(); // Blocks startup + } +} + +// Correct - lazy initialization +class ExpensiveService { + private initialized = false; + + async initialize() { + if (!this.initialized) { + await this.loadLargeDataset(); + this.initialized = true; + } + } + + async doWork() { + await this.initialize(); + // Work here + } +} +``` + +2. **Service Scopes**: Use appropriate scopes: +```typescript +// Wrong - creates new instance every time +{ provide: HeavyService, scope: Scope.transient } + +// Correct - reuse instance +{ provide: HeavyService } // Default: singleton +``` + +3. **Module Optimization**: Avoid unnecessary imports: +```typescript +// Wrong - imports everything +import * as deepkit from '@deepkit/framework'; + +// Correct - import only what you need +import { App } from '@deepkit/app'; +import { Logger } from '@deepkit/logger'; +``` + +## Debugging Techniques + +### Enable Debug Logging + +Add debug logging to understand what's happening: + +```typescript +import { Logger } from '@deepkit/logger'; + +const app = new App({ + providers: [ + { + provide: Logger, + useValue: new Logger([new ConsoleTransport()], 5) // Debug level + } + ] +}); +``` + +### Inspect Service Container + +Check what services are registered: + +```typescript +const app = new App({ + providers: [MyService] +}); + +// After app is built, inspect the container +console.log('Registered providers:', app.serviceContainer.getProviders()); +``` + +### Use TypeScript Strict Mode + +Enable strict TypeScript checking to catch issues early: + +```json +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true + } +} +``` + +### Test Individual Components + +Isolate issues by testing components individually: + +```typescript +// Test service directly +const service = new MyService(mockDependency); +const result = service.doSomething(); + +// Test with DI container +const testing = createTestingApp({ + providers: [MyService, MyDependency] +}); +const service = testing.app.get(MyService); +``` + +## Common Error Messages + +### `Cannot resolve dependency` + +**Error**: `Cannot resolve dependency 'X' of 'Y'` + +**Cause**: Missing provider or circular dependency + +**Fix**: +1. Add the missing provider to your module +2. Check for circular dependencies +3. Ensure the dependency is exported from its module + +### `Invalid configuration` + +**Error**: `ConfigurationValidationError: Invalid value for 'field'` + +**Cause**: Configuration value doesn't match expected type or constraints + +**Fix**: +1. Check environment variable values +2. Verify type constraints in configuration class +3. Use proper type conversion + +### `Command not found` + +**Error**: `Command 'X' not found` + +**Cause**: Command not registered or wrong name + +**Fix**: +1. Ensure command is registered with `app.command()` or controller +2. Check command name spelling +3. Verify controller is in the `controllers` array + +### `Module not found` + +**Error**: `Cannot find module 'X'` + +**Cause**: Incorrect import path or missing dependency + +**Fix**: +1. Check import paths are correct +2. Ensure npm package is installed +3. Verify file exists at specified path + +## Getting Help + +### Enable Verbose Output + +Use the `--help` flag to see available commands and options: + +```bash +ts-node app.ts --help +ts-node app.ts command-name --help +``` + +### Check Version Compatibility + +Ensure all Deepkit packages are compatible versions: + +```bash +npm list @deepkit/app @deepkit/type @deepkit/injector +``` + +### Community Resources + +- [GitHub Issues](https://github.com/deepkit/deepkit-framework/issues) +- [Discord Community](https://discord.gg/deepkit) +- [Documentation](https://deepkit.io/documentation) + +### Creating Minimal Reproduction + +When reporting issues, create a minimal example: + +```typescript +// minimal-repro.ts +import { App } from '@deepkit/app'; + +const app = new App({}); + +app.command('test', () => { + console.log('This should work but doesn\'t'); +}); + +app.run(); +``` + +This helps maintainers understand and fix issues quickly. From 53a1e5c17f4231bd5b7f5a642dfe101b094e470e Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 19:08:18 +0200 Subject: [PATCH 03/15] docs: improve rpc --- .../documentation/rpc/advanced-features.md | 478 ++++++++++++++++++ .../pages/documentation/rpc/collections.md | 342 +++++++++++++ .../documentation/rpc/getting-started.md | 229 ++++++++- .../src/pages/documentation/rpc/interfaces.md | 421 +++++++++++++++ .../pages/documentation/rpc/peer-to-peer.md | 438 ++++++++++++++++ .../documentation/rpc/progress-tracking.md | 249 +++++++++ .../src/pages/documentation/rpc/streaming.md | 255 ++++++++++ .../src/pages/documentation/rpc/transport.md | 308 ++++++++++- 8 files changed, 2711 insertions(+), 9 deletions(-) create mode 100644 website/src/pages/documentation/rpc/advanced-features.md create mode 100644 website/src/pages/documentation/rpc/collections.md create mode 100644 website/src/pages/documentation/rpc/interfaces.md create mode 100644 website/src/pages/documentation/rpc/peer-to-peer.md create mode 100644 website/src/pages/documentation/rpc/progress-tracking.md create mode 100644 website/src/pages/documentation/rpc/streaming.md diff --git a/website/src/pages/documentation/rpc/advanced-features.md b/website/src/pages/documentation/rpc/advanced-features.md new file mode 100644 index 000000000..fd0f5f108 --- /dev/null +++ b/website/src/pages/documentation/rpc/advanced-features.md @@ -0,0 +1,478 @@ +# Advanced Features + +This section covers advanced RPC features including bidirectional communication, custom message types, entity state management, validation, and type-safe interfaces. + +## Type-Safe RPC Interfaces + +For production applications, use `ControllerSymbol` to create type-safe interfaces between client and server. This ensures compile-time safety and better developer experience. + +```typescript +// shared/admin-interface.ts +import { ControllerSymbol } from '@deepkit/rpc'; + +export const AdminRpcInterface = ControllerSymbol('admin'); + +export interface AdminRpcController { + getSystemStats(): Promise; + restartService(serviceName: string): Promise; + getUserActivity(userId: number): Promise; + broadcastMessage(message: string): Promise; +} +``` + +Server implementation: + +```typescript +@rpc.controller(AdminRpcInterface) +class AdminRpcControllerImpl implements AdminRpcController { + async getSystemStats(): Promise { + return { + cpu: process.cpuUsage(), + memory: process.memoryUsage(), + uptime: process.uptime() + }; + } + + async restartService(serviceName: string): Promise { + // Implementation + return true; + } + + async getUserActivity(userId: number): Promise { + // Implementation + return []; + } + + async broadcastMessage(message: string): Promise { + // Implementation + } +} +``` + +Client usage: + +```typescript +const adminController = client.controller(AdminRpcInterface); +const stats = await adminController.getSystemStats(); // Fully typed! +``` + +See [RPC Interfaces](interfaces.md) for complete documentation on type-safe RPC contracts. + +## Bidirectional Communication (Back Controllers) + +Deepkit RPC supports bidirectional communication where the server can call methods on the client. This is useful for notifications, confirmations, and real-time interactions. + +### Server-to-Client Calls + +```typescript +import { RpcKernelConnection } from '@deepkit/rpc'; + +// Client-side controller +@rpc.controller('/client-notifications') +class ClientNotificationController { + @rpc.action() + showNotification(message: string, type: 'info' | 'warning' | 'error'): void { + console.log(`[${type.toUpperCase()}] ${message}`); + // In a real app, this would show a UI notification + } + + @rpc.action() + confirmAction(message: string): Promise { + // In a real app, this would show a confirmation dialog + return Promise.resolve(confirm(message)); + } + + @rpc.action() + updateProgress(current: number, total: number): void { + const percentage = Math.round((current / total) * 100); + console.log(`Progress: ${percentage}%`); + } +} + +// Server-side controller that calls client methods +@rpc.controller('/server-operations') +class ServerOperationsController { + constructor(private connection: RpcKernelConnection) {} + + @rpc.action() + async performLongOperation(): Promise { + const clientController = this.connection.controller('/client-notifications'); + + // Notify client that operation is starting + await clientController.showNotification('Starting long operation...', 'info'); + + // Ask for confirmation + const confirmed = await clientController.confirmAction('This will take a while. Continue?'); + if (!confirmed) { + return 'Operation cancelled by user'; + } + + // Simulate long operation with progress updates + for (let i = 0; i <= 10; i++) { + await clientController.updateProgress(i, 10); + await new Promise(resolve => setTimeout(resolve, 500)); + } + + await clientController.showNotification('Operation completed successfully!', 'info'); + return 'Operation completed'; + } +} +``` + +Client setup: + +```typescript +// Register the client controller +client.registerController(ClientNotificationController, '/client-notifications'); + +// Use the server controller +const serverController = client.controller('/server-operations'); +const result = await serverController.performLongOperation(); +console.log(result); +``` + +## Custom Message Types + +You can define custom message types for specialized communication patterns: + +```typescript +import { createRpcMessage, RpcMessage } from '@deepkit/rpc'; + +// Define custom message types +enum CustomMessageTypes { + // Start from 100 to avoid conflicts with built-in types + HEARTBEAT = 100, + SYSTEM_STATUS = 101, + BROADCAST = 102, +} + +@rpc.controller('/custom-messages') +class CustomMessageController { + constructor(private connection: RpcKernelConnection) { + // Set up custom message handlers + this.connection.onMessage.subscribe(message => { + this.handleCustomMessage(message); + }); + } + + @rpc.action() + sendHeartbeat(): void { + const heartbeatMessage = createRpcMessage(CustomMessageTypes.HEARTBEAT, { + timestamp: Date.now(), + serverId: 'server-1' + }); + this.connection.send(heartbeatMessage); + } + + @rpc.action() + broadcastStatus(status: SystemStatus): void { + const statusMessage = createRpcMessage(CustomMessageTypes.SYSTEM_STATUS, status); + this.connection.send(statusMessage); + } + + private handleCustomMessage(message: RpcMessage): void { + switch (message.type) { + case CustomMessageTypes.HEARTBEAT: + console.log('Received heartbeat:', message.body); + break; + case CustomMessageTypes.SYSTEM_STATUS: + console.log('System status update:', message.body); + break; + } + } +} + +interface SystemStatus { + cpu: number; + memory: number; + uptime: number; +} +``` + +## Entity State Management + +Track changes to entities and synchronize state between client and server: + +```typescript +import { EntityState } from '@deepkit/rpc'; + +@entity.name('document') +class Document { + version: number = 1; + + constructor( + public id: number, + public title: string, + public content: string, + public lastModified: Date = new Date() + ) {} +} + +@rpc.controller('/documents') +class DocumentController { + private documents = new Map(); + private entityState = new EntityState(Document); + + @rpc.action() + getDocument(id: number): Document | undefined { + return this.documents.get(id); + } + + @rpc.action() + getDocumentState(): EntityState { + return this.entityState; + } + + @rpc.action() + updateDocument(document: Document): void { + // Update version and timestamp + document.version++; + document.lastModified = new Date(); + + this.documents.set(document.id, document); + + // Notify entity state of changes + this.entityState.markAsChanged(document); + } + + @rpc.action() + createDocument(title: string, content: string): Document { + const id = Date.now(); + const document = new Document(id, title, content); + this.documents.set(id, document); + + this.entityState.markAsAdded(document); + return document; + } + + @rpc.action() + deleteDocument(id: number): boolean { + const document = this.documents.get(id); + if (document) { + this.documents.delete(id); + this.entityState.markAsRemoved(document); + return true; + } + return false; + } +} +``` + +Client state tracking: + +```typescript +const controller = client.controller('/documents'); + +// Get entity state for change tracking +const entityState = await controller.getDocumentState(); + +// Subscribe to changes +entityState.added.subscribe(document => { + console.log('Document added:', document.title); +}); + +entityState.changed.subscribe(document => { + console.log('Document changed:', document.title, 'version:', document.version); +}); + +entityState.removed.subscribe(document => { + console.log('Document removed:', document.title); +}); + +// Perform operations +const newDoc = await controller.createDocument('My Document', 'Initial content'); +await controller.updateDocument({ ...newDoc, content: 'Updated content' }); +``` + +## Validation + +Use Deepkit's validation system with RPC actions: + +```typescript +import { MinLength, MaxLength, Email, Positive, validate } from '@deepkit/type'; + +@entity.name('user-registration') +class UserRegistration { + @MinLength(2) + @MaxLength(50) + name!: string; + + @Email() + email!: string; + + @MinLength(8) + password!: string; + + @Positive() + age!: number; +} + +@rpc.controller('/user-registration') +class UserRegistrationController { + @rpc.action() + async registerUser(registration: UserRegistration): Promise<{ success: boolean, userId?: number, errors?: string[] }> { + // Validation is automatic, but you can also validate manually + const errors = validate(registration); + if (errors.length > 0) { + return { + success: false, + errors: errors.map(e => e.message) + }; + } + + // Process registration + const userId = await this.createUser(registration); + return { success: true, userId }; + } + + private async createUser(registration: UserRegistration): Promise { + // Implementation + return Date.now(); + } +} +``` + +Client with validation: + +```typescript +const controller = client.controller('/user-registration'); + +try { + const result = await controller.registerUser({ + name: 'John Doe', + email: 'john@example.com', + password: 'securepassword123', + age: 25 + }); + + if (result.success) { + console.log('User registered with ID:', result.userId); + } else { + console.log('Validation errors:', result.errors); + } +} catch (error) { + // Validation errors are thrown automatically for invalid data + console.error('Registration failed:', error.message); +} +``` + +## Action Groups and Metadata + +Organize and annotate your RPC actions: + +```typescript +@rpc.controller('/admin') +class AdminController { + @rpc.action().group('user-management').data('permission', 'admin') + deleteUser(userId: number): Promise { + // Implementation + return Promise.resolve(true); + } + + @rpc.action().group('system').data('permission', 'admin').data('audit', true) + restartSystem(): Promise { + // Implementation + return Promise.resolve(); + } + + @rpc.action().group('reports').data('permission', 'read-only') + generateReport(type: string): Promise { + // Implementation + return Promise.resolve('Report data'); + } +} +``` + +Use groups and metadata in security: + +```typescript +import { RpcKernelSecurity, Session, RpcControllerAccess } from '@deepkit/rpc'; + +class AdminSecurity extends RpcKernelSecurity { + async hasControllerAccess(session: Session, access: RpcControllerAccess): Promise { + // Check action groups + if (access.actionGroups.includes('user-management')) { + return this.hasPermission(session, 'admin'); + } + + // Check action data + const requiredPermission = access.actionData['permission']; + if (requiredPermission) { + return this.hasPermission(session, requiredPermission); + } + + return true; + } + + private hasPermission(session: Session, permission: string): boolean { + // Implementation + return true; + } +} +``` + +## Performance Optimization + +### Connection Pooling + +```typescript +import { RpcKernelConnection } from '@deepkit/rpc'; + +@rpc.controller('/optimized') +class OptimizedController { + private connectionPool = new Map(); + + @rpc.action() + async batchOperation(items: string[]): Promise { + // Process items in batches for better performance + const batchSize = 10; + const results: string[] = []; + + for (let i = 0; i < items.length; i += batchSize) { + const batch = items.slice(i, i + batchSize); + const batchResults = await this.processBatch(batch); + results.push(...batchResults); + } + + return results; + } + + private async processBatch(items: string[]): Promise { + // Process batch + return items.map(item => `processed-${item}`); + } +} +``` + +### Caching + +```typescript +@rpc.controller('/cached') +class CachedController { + private cache = new Map(); + private cacheExpiry = new Map(); + + @rpc.action() + async getExpensiveData(key: string): Promise { + // Check cache first + if (this.cache.has(key) && this.cacheExpiry.get(key)! > Date.now()) { + return this.cache.get(key); + } + + // Compute expensive data + const data = await this.computeExpensiveData(key); + + // Cache for 5 minutes + this.cache.set(key, data); + this.cacheExpiry.set(key, Date.now() + 5 * 60 * 1000); + + return data; + } + + private async computeExpensiveData(key: string): Promise { + // Simulate expensive computation + await new Promise(resolve => setTimeout(resolve, 1000)); + return { key, computed: true, timestamp: Date.now() }; + } +} +``` diff --git a/website/src/pages/documentation/rpc/collections.md b/website/src/pages/documentation/rpc/collections.md new file mode 100644 index 000000000..2d5a3d180 --- /dev/null +++ b/website/src/pages/documentation/rpc/collections.md @@ -0,0 +1,342 @@ +# Collections + +Deepkit RPC provides a powerful `Collection` class for managing lists of entities with built-in state synchronization, pagination, and real-time updates. Collections are particularly useful for managing data sets that need to be kept in sync between client and server. + +## Basic Usage + +```typescript +import { Collection } from '@deepkit/rpc'; +import { entity } from '@deepkit/type'; + +@entity.name('user') +class User { + constructor( + public id: number, + public name: string, + public email: string + ) {} +} + +@rpc.controller('/users') +class UserController { + private users = new Collection(User); + + constructor() { + // Initialize with some data + this.users.set([ + new User(1, 'Alice', 'alice@example.com'), + new User(2, 'Bob', 'bob@example.com'), + new User(3, 'Charlie', 'charlie@example.com') + ]); + this.users.state.total = 100; // Total items available + } + + @rpc.action() + getUsers(): Collection { + return this.users; + } + + @rpc.action() + addUser(user: User): void { + this.users.add(user); + } + + @rpc.action() + removeUser(id: number): void { + this.users.remove(id); + } + + @rpc.action() + updateUser(user: User): void { + this.users.add(user); // Adding with same ID updates the item + } +} +``` + +Client usage: + +```typescript +const controller = client.controller('/users'); + +// Get the collection +const users = await controller.getUsers(); + +console.log('Total users:', users.count()); +console.log('All users:', users.all()); +console.log('User by ID:', users.get(1)); + +// Check if user exists +if (users.has(2)) { + console.log('User 2 exists'); +} + +// Get user index +const userIndex = users.index(users.get(1)!); +console.log('User 1 is at index:', userIndex); +``` + +## Collection State + +Collections maintain state information for pagination and metadata: + +```typescript +@rpc.controller('/products') +class ProductController { + @rpc.action() + getProducts(page: number = 0, limit: number = 10): Collection { + const collection = new Collection(Product); + + // Configure pagination + collection.model.itemsPerPage = limit; + collection.model.skip = page * limit; + collection.model.limit = limit; + collection.model.sort = { name: 'asc' }; + + // Set the actual data for this page + const products = this.getProductsFromDatabase(page, limit); + collection.set(products); + + // Set total count + collection.state.total = this.getTotalProductCount(); + + return collection; + } +} +``` + +Client pagination: + +```typescript +const products = await controller.getProducts(0, 10); + +console.log('Items per page:', products.model.itemsPerPage); +console.log('Current page items:', products.count()); +console.log('Total items:', products.state.total); +console.log('Current skip:', products.model.skip); + +// Calculate pagination info +const totalPages = Math.ceil(products.state.total / products.model.itemsPerPage); +const currentPage = Math.floor(products.model.skip / products.model.itemsPerPage); + +console.log(`Page ${currentPage + 1} of ${totalPages}`); +``` + +## Real-time Updates + +Collections can be used with streaming to provide real-time updates: + +```typescript +import { Subject } from 'rxjs'; + +@rpc.controller('/live-data') +class LiveDataController { + private collection = new Collection(DataItem); + private updates = new Subject>(); + + constructor() { + // Simulate real-time data updates + setInterval(() => { + const newItem = new DataItem( + Date.now(), + `Item ${Date.now()}`, + Math.random() + ); + this.collection.add(newItem); + this.updates.next(this.collection); + }, 2000); + } + + @rpc.action() + getInitialData(): Collection { + return this.collection; + } + + @rpc.action() + subscribeToUpdates(): Subject> { + return this.updates; + } + + @rpc.action() + addItem(item: DataItem): void { + this.collection.add(item); + this.updates.next(this.collection); + } + + @rpc.action() + removeItem(id: number): void { + this.collection.remove(id); + this.updates.next(this.collection); + } +} + +@entity.name('data-item') +class DataItem { + constructor( + public id: number, + public name: string, + public value: number + ) {} +} +``` + +Client real-time updates: + +```typescript +const controller = client.controller('/live-data'); + +// Get initial data +let collection = await controller.getInitialData(); +console.log('Initial items:', collection.count()); + +// Subscribe to updates +const updates = await controller.subscribeToUpdates(); +updates.subscribe(updatedCollection => { + collection = updatedCollection; + console.log('Updated items:', collection.count()); + console.log('Latest item:', collection.all()[collection.count() - 1]); +}); + +// Add new items +await controller.addItem(new DataItem(999, 'Manual Item', 42)); +``` + +## Collection Methods + +The Collection class provides many useful methods: + +```typescript +const collection = new Collection(User); + +// Adding items +collection.add(new User(1, 'Alice', 'alice@example.com')); +collection.set([ + new User(2, 'Bob', 'bob@example.com'), + new User(3, 'Charlie', 'charlie@example.com') +]); + +// Accessing items +const user = collection.get(1); // Get by ID +const allUsers = collection.all(); // Get all items +const userIds = collection.ids(); // Get all IDs +const userMap = collection.map(); // Get as Map + +// Checking existence +const exists = collection.has(1); // Check if ID exists +const isEmpty = collection.empty(); // Check if empty +const count = collection.count(); // Get item count + +// Finding items +const index = collection.index(user); // Get index of item +const page = collection.getPageOf(user, 10); // Get page number for item + +// Modifying +collection.remove(1); // Remove by ID +collection.reset(); // Clear all items +``` + +## Filtering and Sorting + +Collections support client-side filtering and sorting: + +```typescript +@rpc.controller('/inventory') +class InventoryController { + @rpc.action() + getInventory(): Collection { + const collection = new Collection(InventoryItem); + + // Set sorting configuration + collection.model.sort = { name: 'asc', price: 'desc' }; + + const items = [ + new InventoryItem(1, 'Laptop', 999.99, 'Electronics'), + new InventoryItem(2, 'Mouse', 29.99, 'Electronics'), + new InventoryItem(3, 'Desk', 199.99, 'Furniture') + ]; + + collection.set(items); + return collection; + } +} + +@entity.name('inventory-item') +class InventoryItem { + constructor( + public id: number, + public name: string, + public price: number, + public category: string + ) {} +} +``` + +Client-side filtering: + +```typescript +const inventory = await controller.getInventory(); + +// Filter items client-side +const electronics = inventory.all().filter(item => item.category === 'Electronics'); +const expensiveItems = inventory.all().filter(item => item.price > 100); + +console.log('Electronics:', electronics); +console.log('Expensive items:', expensiveItems); + +// Sort configuration is available +console.log('Sort order:', inventory.model.sort); +``` + +## Entity State Management + +Collections work seamlessly with entity state management for tracking changes: + +```typescript +import { EntityState } from '@deepkit/rpc'; + +@rpc.controller('/tasks') +class TaskController { + private tasks = new Collection(Task); + + @rpc.action() + getTasks(): Collection { + return this.tasks; + } + + @rpc.action() + getTaskState(): EntityState { + // Return entity state for change tracking + return new EntityState(Task); + } +} + +@entity.name('task') +class Task { + constructor( + public id: number, + public title: string, + public completed: boolean = false + ) {} +} +``` + +## Performance Considerations + +- Collections are optimized for frequent updates and lookups +- Large collections are automatically chunked during transmission +- Use pagination for very large datasets +- Consider using streaming updates for real-time scenarios +- Entity state tracking helps minimize data transfer + +## Type Safety + +Collections maintain full type safety: + +```typescript +// TypeScript knows the exact type +const userCollection: Collection = await controller.getUsers(); + +// All methods are type-safe +const user: User | undefined = userCollection.get(1); +const users: User[] = userCollection.all(); +const userMap: Map = userCollection.map(); +``` diff --git a/website/src/pages/documentation/rpc/getting-started.md b/website/src/pages/documentation/rpc/getting-started.md index 312039515..0303a1b95 100644 --- a/website/src/pages/documentation/rpc/getting-started.md +++ b/website/src/pages/documentation/rpc/getting-started.md @@ -105,7 +105,43 @@ Types must be explicitly specified and cannot be inferred. This is important bec The normal flow in RPC is that the client can execute functions on the server. However, in Deepkit RPC, it is also possible for the server to execute functions on the client. To allow this, the client can also register a controller. -TODO +```typescript +// Client-side controller +@rpc.controller('/client') +class ClientController { + @rpc.action() + notify(message: string): void { + console.log('Server notification:', message); + } + + @rpc.action() + confirm(question: string): boolean { + return confirm(question); + } +} + +// Register the controller on the client +client.registerController(ClientController, '/client'); + +// Server can now call client methods +@rpc.controller('/server') +class ServerController { + constructor(private connection: RpcKernelConnection) {} + + @rpc.action() + async processData(): Promise { + // Call client controller from server + const clientController = this.connection.controller('/client'); + await clientController.notify('Processing started...'); + + // Ask for confirmation + const confirmed = await clientController.confirm('Continue processing?'); + return confirmed; + } +} +``` + +See [Advanced Features](advanced-features.md) for more information about bidirectional communication. ## Dependency Injection @@ -115,7 +151,67 @@ See also [Dependency Injection](dependency-injection.md#). ## Streaming RxJS -TODO +Deepkit RPC has native support for RxJS Observables, Subjects, and BehaviorSubjects. You can return these types from your RPC actions and the client will receive them as streaming data. + +```typescript +import { Observable, Subject, BehaviorSubject } from 'rxjs'; + +@rpc.controller('/streaming') +class StreamingController { + private chatSubject = new Subject(); + + @rpc.action() + getMessages(): Observable { + return new Observable((observer) => { + observer.next('Welcome!'); + observer.next('How are you?'); + observer.complete(); + }); + } + + @rpc.action() + getChatChannel(): Subject { + return this.chatSubject; + } + + @rpc.action() + sendMessage(message: string): void { + this.chatSubject.next(message); + } + + @rpc.action() + getStatus(): BehaviorSubject { + return new BehaviorSubject('online'); + } +} +``` + +On the client side: + +```typescript +const controller = client.controller('/streaming'); + +// Subscribe to messages +const messages = await controller.getMessages(); +messages.subscribe(message => { + console.log('Received:', message); +}); + +// Subscribe to chat channel +const chat = await controller.getChatChannel(); +chat.subscribe(message => { + console.log('Chat:', message); +}); + +// Send a message (will be received by all subscribers) +await controller.sendMessage('Hello everyone!'); + +// Get current status with BehaviorSubject +const status = await controller.getStatus(); +console.log('Current status:', status.getValue()); +``` + +See [Streaming](streaming.md) for more detailed information. ## Nominal Types @@ -145,3 +241,132 @@ const controller = client.controller('/main'); const user = await controller.getUser(2); user instanceof User; //true when @entity.name is used, and false if not ``` + +## Validation + +Deepkit RPC automatically validates parameters and return values using TypeScript types and validation decorators: + +```typescript +import { MinLength, MaxLength, Email, Positive } from '@deepkit/type'; + +@entity.name('user-input') +class UserInput { + @MinLength(2) + @MaxLength(50) + name!: string; + + @Email() + email!: string; + + @Positive() + age!: number; +} + +@rpc.controller('/validation') +class ValidationController { + @rpc.action() + createUser(input: UserInput): { success: boolean, id?: number } { + // Validation happens automatically before this method is called + // If validation fails, an error is thrown to the client + + const userId = Math.floor(Math.random() * 1000); + return { success: true, id: userId }; + } + + @rpc.action() + updateAge(userId: number, @Positive() newAge: number): boolean { + // Individual parameter validation + console.log(`Updating user ${userId} age to ${newAge}`); + return true; + } +} +``` + +Client usage with validation: + +```typescript +const controller = client.controller('/validation'); + +try { + // This will succeed + const result = await controller.createUser({ + name: 'John Doe', + email: 'john@example.com', + age: 25 + }); + console.log('User created:', result); +} catch (error) { + // This will catch validation errors + console.error('Validation failed:', error.message); +} + +try { + // This will fail validation (negative age) + await controller.updateAge(1, -5); +} catch (error) { + console.error('Age validation failed:', error.message); +} +``` + +See [Advanced Features](advanced-features.md) for more validation examples. + +## RPC Interfaces + +For larger applications, it's recommended to use RPC interfaces to define type-safe contracts between your client and server: + +```typescript +// shared/interfaces.ts +import { ControllerSymbol } from '@deepkit/rpc'; + +const UserRpcInterface = ControllerSymbol('user'); + +interface UserRpcController { + getUser(id: number): Promise; + createUser(userData: CreateUserData): Promise; + listUsers(): Promise; +} + +export { UserRpcInterface }; +export type { UserRpcController }; +``` + +Server implementation: + +```typescript +// server.ts +import { UserRpcInterface, UserRpcController } from './shared/interfaces.js'; + +@rpc.controller(UserRpcInterface) +class UserRpcControllerImpl implements UserRpcController { + async getUser(id: number): Promise { + // Implementation + return new User(id, 'John Doe', 'john@example.com'); + } + + async createUser(userData: CreateUserData): Promise { + // Implementation + return new User(Date.now(), userData.name, userData.email); + } + + async listUsers(): Promise { + // Implementation + return []; + } +} +``` + +Client usage: + +```typescript +// client.ts +import { UserRpcInterface, UserRpcController } from './shared/interfaces.js'; + +const client = new RpcWebSocketClient('ws://127.0.0.1:8081'); +const userController = client.controller(UserRpcInterface); + +// Full type safety! +const user = await userController.getUser(1); +const users = await userController.listUsers(); +``` + +See [RPC Interfaces](interfaces.md) for comprehensive documentation on creating type-safe RPC contracts. diff --git a/website/src/pages/documentation/rpc/interfaces.md b/website/src/pages/documentation/rpc/interfaces.md new file mode 100644 index 000000000..0ea7b53f4 --- /dev/null +++ b/website/src/pages/documentation/rpc/interfaces.md @@ -0,0 +1,421 @@ +# RPC Interfaces + +RPC interfaces provide a type-safe way to define contracts between your backend and frontend. Using `ControllerSymbol` and TypeScript interfaces, you can ensure that both sides of your application stay in sync and benefit from full type safety. + +## Basic Interface Definition + +Create shared interfaces that define the contract between client and server: + +```typescript +// shared/interfaces.ts +import { ControllerSymbol } from '@deepkit/rpc'; + +// Define the controller symbol +const UserRpcInterface = ControllerSymbol('user'); + +// Define the interface contract +interface UserRpcController { + getUser(id: number): Promise; + createUser(userData: CreateUserData): Promise; + updateUser(id: number, updates: Partial): Promise; + deleteUser(id: number): Promise; + listUsers(page?: number, limit?: number): Promise; +} + +// Export both the symbol and interface +export { UserRpcInterface }; +export type { UserRpcController }; +``` + +### Controller Symbol with Entities + +You can also specify entity classes that should be automatically registered for the controller: + +```typescript +// shared/interfaces.ts +import { ControllerSymbol } from '@deepkit/rpc'; +import { User, CreateUserData } from './types.js'; + +// Include entity classes for automatic registration +const UserRpcInterface = ControllerSymbol('user', [User, CreateUserData]); + +interface UserRpcController { + getUser(id: number): Promise; + createUser(userData: CreateUserData): Promise; + listUsers(): Promise; +} + +export { UserRpcInterface }; +export type { UserRpcController }; +``` + +This ensures that the `User` and `CreateUserData` classes are properly registered for serialization when using this controller. + +## Server Implementation + +Implement the interface on the server side: + +```typescript +// server/controllers/user.controller.ts +import { rpc } from '@deepkit/rpc'; +import { UserRpcInterface, UserRpcController } from '../shared/interfaces.js'; + +@rpc.controller(UserRpcInterface) +class UserRpcControllerImpl implements UserRpcController { + constructor(private userService: UserService) {} + + async getUser(id: number): Promise { + return await this.userService.findById(id); + } + + async createUser(userData: CreateUserData): Promise { + return await this.userService.create(userData); + } + + async updateUser(id: number, updates: Partial): Promise { + return await this.userService.update(id, updates); + } + + async deleteUser(id: number): Promise { + return await this.userService.delete(id); + } + + async listUsers(page: number = 0, limit: number = 10): Promise { + return await this.userService.findMany({ page, limit }); + } +} +``` + +## Client Usage + +Use the interface on the client side with full type safety: + +```typescript +// client/services/user.service.ts +import { DeepkitClient } from '@deepkit/rpc'; +import { UserRpcInterface, UserRpcController } from '../shared/interfaces.js'; + +class UserService { + private userController: UserRpcController; + + constructor(private client: DeepkitClient) { + // Get typed controller using the symbol + this.userController = client.controller(UserRpcInterface); + } + + async getUser(id: number): Promise { + // Full type safety - TypeScript knows the exact method signature + return await this.userController.getUser(id); + } + + async createUser(userData: CreateUserData): Promise { + return await this.userController.createUser(userData); + } + + async updateUser(id: number, updates: Partial): Promise { + return await this.userController.updateUser(id, updates); + } + + async deleteUser(id: number): Promise { + return await this.userController.deleteUser(id); + } + + async listUsers(page?: number, limit?: number): Promise { + return await this.userController.listUsers(page, limit); + } +} +``` + +## Multiple Interfaces + +Organize your application with multiple RPC interfaces: + +```typescript +// shared/interfaces.ts +import { ControllerSymbol } from '@deepkit/rpc'; + +// User management interface +const UserRpcInterface = ControllerSymbol('user'); +interface UserRpcController { + getUser(id: number): Promise; + createUser(userData: CreateUserData): Promise; + listUsers(filters?: UserFilters): Promise; +} + +// Product management interface +const ProductRpcInterface = ControllerSymbol('product'); +interface ProductRpcController { + getProduct(id: number): Promise; + createProduct(productData: CreateProductData): Promise; + searchProducts(query: string): Promise; + updateInventory(id: number, quantity: number): Promise; +} + +// Order management interface +const OrderRpcInterface = ControllerSymbol('order'); +interface OrderRpcController { + createOrder(orderData: CreateOrderData): Promise; + getOrder(id: number): Promise; + updateOrderStatus(id: number, status: OrderStatus): Promise; + getUserOrders(userId: number): Promise; +} + +export { + UserRpcInterface, + ProductRpcInterface, + OrderRpcInterface +}; + +export type { + UserRpcController, + ProductRpcController, + OrderRpcController +}; +``` + +## Streaming Interfaces + +Define interfaces that include streaming methods: + +```typescript +// shared/streaming-interfaces.ts +import { Observable, Subject } from 'rxjs'; +import { ControllerSymbol } from '@deepkit/rpc'; + +const ChatRpcInterface = ControllerSymbol('chat'); + +interface ChatRpcController { + // Regular methods + joinRoom(roomId: string, userId: string): Promise; + leaveRoom(roomId: string, userId: string): Promise; + + // Streaming methods + getRoomMessages(roomId: string): Observable; + subscribeToRoom(roomId: string): Subject; + + // Send message (triggers updates to subscribers) + sendMessage(roomId: string, message: ChatMessage): Promise; +} + +export { ChatRpcInterface }; +export type { ChatRpcController }; +``` + +Server implementation with streaming: + +```typescript +@rpc.controller(ChatRpcInterface) +class ChatRpcControllerImpl implements ChatRpcController { + private roomSubjects = new Map>(); + + async joinRoom(roomId: string, userId: string): Promise { + // Implementation + return await this.chatService.joinRoom(roomId, userId); + } + + async leaveRoom(roomId: string, userId: string): Promise { + await this.chatService.leaveRoom(roomId, userId); + } + + getRoomMessages(roomId: string): Observable { + return new Observable(observer => { + // Stream historical messages + this.chatService.getMessages(roomId).then(messages => { + messages.forEach(msg => observer.next(msg)); + observer.complete(); + }); + }); + } + + subscribeToRoom(roomId: string): Subject { + if (!this.roomSubjects.has(roomId)) { + this.roomSubjects.set(roomId, new Subject()); + } + return this.roomSubjects.get(roomId)!; + } + + async sendMessage(roomId: string, message: ChatMessage): Promise { + await this.chatService.saveMessage(roomId, message); + + // Notify subscribers + const subject = this.roomSubjects.get(roomId); + if (subject) { + subject.next(message); + } + } +} +``` + +## Interface Validation + +Ensure your implementations match the interface using TypeScript: + +```typescript +// This will cause a TypeScript error if the implementation doesn't match +const _typeCheck: UserRpcController = new UserRpcControllerImpl(); + +// Or use a more explicit type assertion +class UserRpcControllerImpl implements UserRpcController { + // TypeScript will enforce that all interface methods are implemented + // with the correct signatures +} +``` + +## Shared Types + +Define shared types that are used across interfaces: + +```typescript +// shared/types.ts +import { entity } from '@deepkit/type'; + +@entity.name('user') +export class User { + constructor( + public id: number, + public name: string, + public email: string, + public createdAt: Date = new Date() + ) {} +} + +@entity.name('create-user-data') +export class CreateUserData { + constructor( + public name: string, + public email: string, + public password: string + ) {} +} + +@entity.name('user-filters') +export class UserFilters { + name?: string; + email?: string; + createdAfter?: Date; + createdBefore?: Date; + limit?: number; + offset?: number; +} +``` + +## Error Handling in Interfaces + +Define custom errors in your interfaces: + +```typescript +// shared/errors.ts +import { entity } from '@deepkit/type'; + +@entity.name('@error:user-not-found') +export class UserNotFoundError extends Error { + constructor(public userId: number) { + super(`User with ID ${userId} not found`); + } +} + +@entity.name('@error:validation-error') +export class ValidationError extends Error { + constructor(public field: string, public message: string) { + super(`Validation failed for ${field}: ${message}`); + } +} + +// Use in interface +interface UserRpcController { + getUser(id: number): Promise; // Can throw UserNotFoundError + createUser(userData: CreateUserData): Promise; // Can throw ValidationError +} +``` + +Client error handling: + +```typescript +try { + const user = await userController.getUser(999); +} catch (error) { + if (error instanceof UserNotFoundError) { + console.log('User not found:', error.userId); + } else if (error instanceof ValidationError) { + console.log('Validation error:', error.field, error.message); + } else { + console.error('Unexpected error:', error); + } +} +``` + +## Framework Integration + +Use interfaces with the Deepkit Framework: + +```typescript +// app.ts +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; + +const app = new App({ + controllers: [ + UserRpcControllerImpl, + ProductRpcControllerImpl, + OrderRpcControllerImpl, + ChatRpcControllerImpl + ], + imports: [new FrameworkModule()] +}); + +app.run(); +``` + +## Testing with Interfaces + +Test your implementations using the interfaces: + +```typescript +// tests/user.controller.spec.ts +import { createTestingApp } from '@deepkit/framework'; +import { UserRpcInterface, UserRpcController } from '../shared/interfaces.js'; + +test('user controller', async () => { + const testing = createTestingApp({ + controllers: [UserRpcControllerImpl], + providers: [UserService] + }); + + await testing.startServer(); + + const client = testing.createRpcClient(); + const userController = client.controller(UserRpcInterface); + + // Test with full type safety + const user = await userController.createUser({ + name: 'Test User', + email: 'test@example.com', + password: 'password123' + }); + + expect(user.name).toBe('Test User'); + expect(user.email).toBe('test@example.com'); + + await testing.stopServer(); +}); +``` + +## Benefits of RPC Interfaces + +1. **Type Safety**: Full TypeScript type checking between client and server +2. **Contract Definition**: Clear API contracts that both sides must follow +3. **Refactoring Safety**: Changes to interfaces are caught at compile time +4. **IDE Support**: Full IntelliSense and auto-completion +5. **Documentation**: Interfaces serve as living documentation +6. **Testing**: Easy to mock and test with known interfaces +7. **Team Collaboration**: Clear boundaries between frontend and backend teams + +## Best Practices + +1. **Keep interfaces in shared packages** that both client and server can import +2. **Use meaningful names** for controller symbols +3. **Version your interfaces** when making breaking changes +4. **Document complex methods** with JSDoc comments +5. **Use entity decorators** for all data types +6. **Handle errors explicitly** in interface definitions +7. **Test interface implementations** thoroughly diff --git a/website/src/pages/documentation/rpc/peer-to-peer.md b/website/src/pages/documentation/rpc/peer-to-peer.md new file mode 100644 index 000000000..9a34de1ef --- /dev/null +++ b/website/src/pages/documentation/rpc/peer-to-peer.md @@ -0,0 +1,438 @@ +# Peer-to-Peer Communication + +Deepkit RPC supports peer-to-peer communication through the Deepkit Broker, allowing clients to communicate directly with each other through the server. This enables powerful distributed architectures and real-time collaboration features. + +## Basic Peer-to-Peer Setup + +### Server Configuration + +First, set up a server that acts as a broker for peer-to-peer communication: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; + +@rpc.controller('/broker') +class BrokerController { + @rpc.action() + ping(): string { + return 'pong'; + } +} + +const app = new App({ + controllers: [BrokerController], + imports: [new FrameworkModule({ + rpc: { + // Enable peer-to-peer features + enablePeerToPeer: true + } + })] +}); + +app.run(); +``` + +### Client Registration as Peer + +Clients must register themselves as peers to participate in P2P communication: + +```typescript +import { RpcWebSocketClient } from '@deepkit/rpc'; + +// Client 1 - Register as peer +const client1 = new RpcWebSocketClient('ws://localhost:8080'); +await client1.connect(); +await client1.registerAsPeer('client-1'); + +// Client 2 - Register as peer +const client2 = new RpcWebSocketClient('ws://localhost:8080'); +await client2.connect(); +await client2.registerAsPeer('client-2'); +``` + +## Peer Controllers + +Peers can register controllers that other peers can call: + +```typescript +@entity.name('chat-message') +class ChatMessage { + constructor( + public from: string, + public message: string, + public timestamp: Date = new Date() + ) {} +} + +@rpc.controller('/chat') +class ChatPeerController { + private messages: ChatMessage[] = []; + + @rpc.action() + sendMessage(message: ChatMessage): void { + this.messages.push(message); + console.log(`Received message from ${message.from}: ${message.message}`); + } + + @rpc.action() + getMessages(): ChatMessage[] { + return this.messages; + } + + @rpc.action() + clearMessages(): void { + this.messages = []; + } +} + +// Register the controller on both clients +client1.registerController('/chat', new ChatPeerController()); +client2.registerController('/chat', new ChatPeerController()); +``` + +## Peer-to-Peer Communication + +Once peers are registered, they can communicate directly: + +```typescript +// Client 1 sends message to Client 2 +const client2ChatController = client1.peer('client-2').controller('/chat'); +await client2ChatController.sendMessage(new ChatMessage('client-1', 'Hello from client 1!')); + +// Client 2 sends message to Client 1 +const client1ChatController = client2.peer('client-1').controller('/chat'); +await client1ChatController.sendMessage(new ChatMessage('client-2', 'Hello back from client 2!')); + +// Get messages from peer +const messages = await client1ChatController.getMessages(); +console.log('Messages on client 1:', messages); +``` + +## Real-time Collaboration + +Use peer-to-peer communication for real-time collaboration features: + +```typescript +@entity.name('document-edit') +class DocumentEdit { + constructor( + public documentId: string, + public userId: string, + public operation: 'insert' | 'delete' | 'replace', + public position: number, + public content: string, + public timestamp: Date = new Date() + ) {} +} + +@rpc.controller('/collaboration') +class CollaborationController { + private document: string = ''; + private edits: DocumentEdit[] = []; + private collaborators = new Set(); + + @rpc.action() + joinDocument(documentId: string, userId: string): { document: string, edits: DocumentEdit[] } { + this.collaborators.add(userId); + return { + document: this.document, + edits: this.edits + }; + } + + @rpc.action() + applyEdit(edit: DocumentEdit): void { + this.edits.push(edit); + this.applyEditToDocument(edit); + console.log(`Edit applied by ${edit.userId}: ${edit.operation} at ${edit.position}`); + } + + @rpc.action() + leaveDocument(userId: string): void { + this.collaborators.delete(userId); + } + + @rpc.action() + getCollaborators(): string[] { + return Array.from(this.collaborators); + } + + private applyEditToDocument(edit: DocumentEdit): void { + switch (edit.operation) { + case 'insert': + this.document = this.document.slice(0, edit.position) + + edit.content + + this.document.slice(edit.position); + break; + case 'delete': + this.document = this.document.slice(0, edit.position) + + this.document.slice(edit.position + edit.content.length); + break; + case 'replace': + this.document = this.document.slice(0, edit.position) + + edit.content + + this.document.slice(edit.position + 1); + break; + } + } +} +``` + +Usage in collaborative editing: + +```typescript +// Multiple clients join the same document +const docId = 'shared-document-1'; + +// Client 1 joins +client1.registerController('/collaboration', new CollaborationController()); +const client1Collab = client1.peer('client-1').controller('/collaboration'); +await client1Collab.joinDocument(docId, 'user-1'); + +// Client 2 joins +client2.registerController('/collaboration', new CollaborationController()); +const client2Collab = client2.peer('client-2').controller('/collaboration'); +await client2Collab.joinDocument(docId, 'user-2'); + +// Client 1 makes an edit and notifies Client 2 +const edit = new DocumentEdit(docId, 'user-1', 'insert', 0, 'Hello '); +await client1Collab.applyEdit(edit); + +// Notify other collaborators +const client2CollabFromClient1 = client1.peer('client-2').controller('/collaboration'); +await client2CollabFromClient1.applyEdit(edit); +``` + +## Peer Discovery + +Discover available peers and their capabilities: + +```typescript +@rpc.controller('/discovery') +class PeerDiscoveryController { + private capabilities: string[] = []; + + @rpc.action() + registerCapabilities(capabilities: string[]): void { + this.capabilities = capabilities; + } + + @rpc.action() + getCapabilities(): string[] { + return this.capabilities; + } + + @rpc.action() + ping(): { peerId: string, timestamp: Date } { + return { + peerId: 'current-peer', + timestamp: new Date() + }; + } +} + +// Register capabilities +client1.registerController('/discovery', new PeerDiscoveryController()); +const discovery1 = client1.peer('client-1').controller('/discovery'); +await discovery1.registerCapabilities(['chat', 'file-sharing', 'video-call']); + +client2.registerController('/discovery', new PeerDiscoveryController()); +const discovery2 = client2.peer('client-2').controller('/discovery'); +await discovery2.registerCapabilities(['chat', 'screen-sharing']); + +// Discover peer capabilities +const client2Capabilities = await client1.peer('client-2').controller('/discovery').getCapabilities(); +console.log('Client 2 capabilities:', client2Capabilities); +``` + +## File Sharing Between Peers + +Implement file sharing using peer-to-peer communication: + +```typescript +@entity.name('file-chunk') +class FileChunk { + constructor( + public fileId: string, + public chunkIndex: number, + public totalChunks: number, + public data: Uint8Array, + public checksum: string + ) {} +} + +@rpc.controller('/file-sharing') +class FileSharingController { + private receivedChunks = new Map>(); + private sharedFiles = new Map(); + + @rpc.action() + shareFile(fileId: string, fileName: string, fileData: Uint8Array): void { + this.sharedFiles.set(fileId, fileData); + console.log(`File ${fileName} (${fileId}) is now available for sharing`); + } + + @rpc.action() + requestFile(fileId: string): Observable { + const fileData = this.sharedFiles.get(fileId); + if (!fileData) { + throw new Error(`File ${fileId} not found`); + } + + return new Observable(observer => { + const chunkSize = 64 * 1024; // 64KB chunks + const totalChunks = Math.ceil(fileData.length / chunkSize); + + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, fileData.length); + const chunkData = fileData.slice(start, end); + + const chunk = new FileChunk( + fileId, + i, + totalChunks, + chunkData, + this.calculateChecksum(chunkData) + ); + + setTimeout(() => observer.next(chunk), i * 10); // Throttle chunks + } + + setTimeout(() => observer.complete(), totalChunks * 10); + }); + } + + @rpc.action() + receiveFileChunk(chunk: FileChunk): void { + if (!this.receivedChunks.has(chunk.fileId)) { + this.receivedChunks.set(chunk.fileId, new Map()); + } + + const fileChunks = this.receivedChunks.get(chunk.fileId)!; + fileChunks.set(chunk.chunkIndex, chunk); + + console.log(`Received chunk ${chunk.chunkIndex + 1}/${chunk.totalChunks} for file ${chunk.fileId}`); + + // Check if file is complete + if (fileChunks.size === chunk.totalChunks) { + this.assembleFile(chunk.fileId); + } + } + + private assembleFile(fileId: string): void { + const chunks = this.receivedChunks.get(fileId)!; + const sortedChunks = Array.from(chunks.values()).sort((a, b) => a.chunkIndex - b.chunkIndex); + + const totalSize = sortedChunks.reduce((sum, chunk) => sum + chunk.data.length, 0); + const assembledFile = new Uint8Array(totalSize); + + let offset = 0; + for (const chunk of sortedChunks) { + assembledFile.set(chunk.data, offset); + offset += chunk.data.length; + } + + this.sharedFiles.set(fileId, assembledFile); + console.log(`File ${fileId} assembled successfully (${totalSize} bytes)`); + } + + private calculateChecksum(data: Uint8Array): string { + // Simple checksum implementation + let sum = 0; + for (let i = 0; i < data.length; i++) { + sum += data[i]; + } + return sum.toString(16); + } +} +``` + +File sharing usage: + +```typescript +// Client 1 shares a file +client1.registerController('/file-sharing', new FileSharingController()); +const fileSharing1 = client1.peer('client-1').controller('/file-sharing'); + +const fileData = new Uint8Array(1024 * 1024); // 1MB file +await fileSharing1.shareFile('file-123', 'document.pdf', fileData); + +// Client 2 requests the file +client2.registerController('/file-sharing', new FileSharingController()); +const fileSharing2 = client2.peer('client-2').controller('/file-sharing'); + +const fileSharing1FromClient2 = client2.peer('client-1').controller('/file-sharing'); +const fileStream = await fileSharing1FromClient2.requestFile('file-123'); + +fileStream.subscribe({ + next: (chunk) => { + // Forward chunk to local file sharing controller + fileSharing2.receiveFileChunk(chunk); + }, + complete: () => { + console.log('File transfer completed'); + }, + error: (error) => { + console.error('File transfer error:', error); + } +}); +``` + +## Security Considerations + +Implement security for peer-to-peer communication: + +```typescript +import { RpcKernelSecurity, Session } from '@deepkit/rpc'; + +class P2PSecurity extends RpcKernelSecurity { + async isAllowedToRegisterAsPeer(session: Session, peerId: string): Promise { + // Validate peer registration + return session.isAuthenticated() && this.isValidPeerId(peerId); + } + + async isAllowedToSendToPeer(session: Session, peerId: string): Promise { + // Control which peers can communicate + return session.isAuthenticated() && this.canCommunicateWith(session, peerId); + } + + private isValidPeerId(peerId: string): boolean { + // Validate peer ID format + return /^[a-zA-Z0-9-_]+$/.test(peerId) && peerId.length <= 50; + } + + private canCommunicateWith(session: Session, peerId: string): boolean { + // Implement your communication rules + return true; + } +} +``` + +## Performance Optimization + +- Use efficient serialization for large data transfers +- Implement chunking for file transfers +- Consider compression for text-based communication +- Use connection pooling for multiple peer connections +- Implement proper cleanup when peers disconnect + +## Error Handling + +Handle peer communication errors gracefully: + +```typescript +try { + const peerController = client.peer('target-peer').controller('/controller'); + await peerController.someAction(); +} catch (error) { + if (error.message.includes('Peer controller not registered')) { + console.log('Target peer is not available'); + } else if (error.message.includes('Peer not found')) { + console.log('Target peer is not connected'); + } else { + console.error('Communication error:', error); + } +} +``` diff --git a/website/src/pages/documentation/rpc/progress-tracking.md b/website/src/pages/documentation/rpc/progress-tracking.md new file mode 100644 index 000000000..f2063a675 --- /dev/null +++ b/website/src/pages/documentation/rpc/progress-tracking.md @@ -0,0 +1,249 @@ +# Progress Tracking + +Deepkit RPC automatically provides progress tracking for large data transfers. When sending or receiving large payloads, the data is automatically chunked and progress information is made available to track upload and download progress. + +## Basic Usage + +```typescript +import { ClientProgress } from '@deepkit/rpc'; + +// Track download progress +const progress = ClientProgress.track(); +progress.download.subscribe(info => { + console.log(`Download: ${info.current}/${info.total} bytes (${Math.round(info.progress * 100)}%)`); +}); + +const controller = client.controller('/files'); +const fileData = await controller.downloadFile('large-file.zip'); + +// Track upload progress +const uploadProgress = ClientProgress.track(); +uploadProgress.upload.subscribe(info => { + console.log(`Upload: ${info.current}/${info.total} bytes (${Math.round(info.progress * 100)}%)`); +}); + +const fileBuffer = new Uint8Array(1024 * 1024); // 1MB file +await controller.uploadFile(fileBuffer); +``` + +## Server Implementation + +No special implementation is required on the server side. Progress tracking works automatically with any RPC action that transfers large data: + +```typescript +@rpc.controller('/files') +class FileController { + @rpc.action() + downloadFile(filename: string): Uint8Array { + // Return large file data + return fs.readFileSync(filename); + } + + @rpc.action() + uploadFile(data: Uint8Array): boolean { + // Process uploaded file + fs.writeFileSync('uploaded-file', data); + return true; + } + + @rpc.action() + processLargeData(data: { items: any[] }): { processed: number } { + // Even complex objects with large data trigger progress tracking + return { processed: data.items.length }; + } +} +``` + +## Progress Information + +The progress object provides detailed information about the transfer: + +```typescript +interface ProgressInfo { + current: number; // Current bytes transferred + total: number; // Total bytes to transfer + progress: number; // Progress as decimal (0.0 to 1.0) +} +``` + +## Multiple Concurrent Transfers + +Each `ClientProgress.track()` call creates an independent progress tracker: + +```typescript +// Track multiple downloads simultaneously +const download1Progress = ClientProgress.track(); +const download2Progress = ClientProgress.track(); + +download1Progress.download.subscribe(info => { + console.log(`File 1: ${Math.round(info.progress * 100)}%`); +}); + +download2Progress.download.subscribe(info => { + console.log(`File 2: ${Math.round(info.progress * 100)}%`); +}); + +// Start downloads concurrently +const [file1, file2] = await Promise.all([ + controller.downloadFile('file1.zip'), + controller.downloadFile('file2.zip') +]); +``` + +## Progress with Streaming + +Progress tracking also works with streaming data: + +```typescript +@rpc.controller('/stream') +class StreamController { + @rpc.action() + streamLargeDataset(): Observable { + return new Observable(observer => { + // Stream large dataset + for (let i = 0; i < 1000; i++) { + observer.next({ + id: i, + data: new Uint8Array(1024) // 1KB per chunk + }); + } + observer.complete(); + }); + } +} + +// Client with progress tracking +const progress = ClientProgress.track(); +progress.download.subscribe(info => { + console.log(`Streaming progress: ${Math.round(info.progress * 100)}%`); +}); + +const stream = await controller.streamLargeDataset(); +stream.subscribe(chunk => { + console.log('Received chunk:', chunk.id); +}); +``` + +## Custom Progress UI + +You can easily integrate progress tracking with UI components: + +```typescript +class FileUploadComponent { + async uploadFile(file: File) { + const progress = ClientProgress.track(); + + // Update progress bar + progress.upload.subscribe(info => { + this.updateProgressBar(info.progress); + this.updateStatusText(`${info.current}/${info.total} bytes`); + }); + + try { + const fileData = new Uint8Array(await file.arrayBuffer()); + const result = await this.controller.uploadFile(fileData); + this.showSuccess('File uploaded successfully'); + } catch (error) { + this.showError('Upload failed: ' + error.message); + } + } + + private updateProgressBar(progress: number) { + const progressBar = document.getElementById('progress-bar'); + if (progressBar) { + progressBar.style.width = `${progress * 100}%`; + } + } + + private updateStatusText(text: string) { + const statusElement = document.getElementById('status'); + if (statusElement) { + statusElement.textContent = text; + } + } +} +``` + +## Automatic Chunking + +Deepkit RPC automatically chunks large messages. The chunking behavior can be configured: + +```typescript +// When creating the client, you can configure chunk size +const client = new RpcWebSocketClient('ws://localhost:8080', { + // Chunk size in bytes (default: 64KB) + chunkSize: 64 * 1024 +}); +``` + +## Progress with Complex Objects + +Progress tracking works with any serializable data, including complex nested objects: + +```typescript +interface LargeDataset { + metadata: { + name: string; + version: string; + }; + records: Array<{ + id: number; + data: Uint8Array; + properties: Record; + }>; +} + +@rpc.controller('/data') +class DataController { + @rpc.action() + processDataset(dataset: LargeDataset): { processed: number } { + // Process the large dataset + return { processed: dataset.records.length }; + } + + @rpc.action() + exportDataset(): LargeDataset { + // Return large dataset + return { + metadata: { name: 'Export', version: '1.0' }, + records: Array.from({ length: 10000 }, (_, i) => ({ + id: i, + data: new Uint8Array(1024), + properties: { index: i, timestamp: new Date() } + })) + }; + } +} +``` + +## Error Handling with Progress + +Progress tracking continues to work even when errors occur: + +```typescript +const progress = ClientProgress.track(); +let totalBytesTransferred = 0; + +progress.upload.subscribe(info => { + totalBytesTransferred = info.current; + console.log(`Transferred: ${info.current} bytes`); +}); + +try { + await controller.uploadFile(largeFile); +} catch (error) { + console.log(`Transfer failed after ${totalBytesTransferred} bytes`); + console.error('Error:', error.message); +} +``` + +## Performance Considerations + +- Progress tracking has minimal overhead for small messages +- Large files are automatically streamed in chunks to avoid memory issues +- Progress events are throttled to avoid overwhelming the UI +- Cleanup is automatic when transfers complete or fail + +## Browser Compatibility + +Progress tracking works in all modern browsers and Node.js environments. The underlying chunking mechanism uses efficient binary protocols for optimal performance. diff --git a/website/src/pages/documentation/rpc/streaming.md b/website/src/pages/documentation/rpc/streaming.md new file mode 100644 index 000000000..68d3d9154 --- /dev/null +++ b/website/src/pages/documentation/rpc/streaming.md @@ -0,0 +1,255 @@ +# Streaming with RxJS + +Deepkit RPC provides native support for RxJS streaming, allowing you to work with real-time data flows between client and server. You can return `Observable`, `Subject`, or `BehaviorSubject` from your RPC actions, and they will be automatically serialized and streamed to the client. + +## Observable + +Use `Observable` for one-way data streams from server to client: + +```typescript +import { Observable } from 'rxjs'; + +@rpc.controller('/data') +class DataController { + @rpc.action() + getSensorData(): Observable<{ temperature: number, humidity: number }> { + return new Observable(observer => { + const interval = setInterval(() => { + observer.next({ + temperature: Math.random() * 30 + 10, + humidity: Math.random() * 100 + }); + }, 1000); + + // Cleanup when client unsubscribes + return () => clearInterval(interval); + }); + } + + @rpc.action() + getFileContent(filename: string): Observable { + return new Observable(observer => { + // Simulate reading file line by line + const lines = ['Line 1', 'Line 2', 'Line 3']; + lines.forEach((line, index) => { + setTimeout(() => { + observer.next(line); + if (index === lines.length - 1) { + observer.complete(); + } + }, index * 100); + }); + }); + } +} +``` + +Client usage: + +```typescript +const controller = client.controller('/data'); + +// Subscribe to sensor data +const sensorData = await controller.getSensorData(); +const subscription = sensorData.subscribe({ + next: (data) => console.log('Sensor:', data), + error: (err) => console.error('Error:', err), + complete: () => console.log('Stream completed') +}); + +// Unsubscribe when done +setTimeout(() => subscription.unsubscribe(), 5000); + +// Read file content +const fileContent = await controller.getFileContent('example.txt'); +fileContent.subscribe(line => console.log('Line:', line)); +``` + +## Subject + +Use `Subject` for bidirectional communication where multiple clients can subscribe and the server can push data: + +```typescript +import { Subject } from 'rxjs'; + +@rpc.controller('/chat') +class ChatController { + private chatRooms = new Map>(); + + @rpc.action() + joinRoom(roomName: string): Subject { + if (!this.chatRooms.has(roomName)) { + this.chatRooms.set(roomName, new Subject()); + } + return this.chatRooms.get(roomName)!; + } + + @rpc.action() + sendMessage(roomName: string, message: ChatMessage): void { + const room = this.chatRooms.get(roomName); + if (room) { + room.next(message); + } + } + + @rpc.action() + closeRoom(roomName: string): void { + const room = this.chatRooms.get(roomName); + if (room) { + room.complete(); + this.chatRooms.delete(roomName); + } + } +} + +interface ChatMessage { + user: string; + message: string; + timestamp: Date; +} +``` + +Client usage: + +```typescript +const controller = client.controller('/chat'); + +// Join a chat room +const chatRoom = await controller.joinRoom('general'); +chatRoom.subscribe(message => { + console.log(`${message.user}: ${message.message}`); +}); + +// Send messages +await controller.sendMessage('general', { + user: 'Alice', + message: 'Hello everyone!', + timestamp: new Date() +}); +``` + +## BehaviorSubject + +Use `BehaviorSubject` when you need to provide the current state immediately to new subscribers: + +```typescript +import { BehaviorSubject } from 'rxjs'; + +@rpc.controller('/status') +class StatusController { + private systemStatus = new BehaviorSubject({ + cpu: 0, + memory: 0, + disk: 0, + status: 'idle' + }); + + constructor() { + // Update status every second + setInterval(() => { + this.systemStatus.next({ + cpu: Math.random() * 100, + memory: Math.random() * 100, + disk: Math.random() * 100, + status: 'running' + }); + }, 1000); + } + + @rpc.action() + getSystemStatus(): BehaviorSubject { + return this.systemStatus; + } + + @rpc.action() + getCurrentStatus(): SystemStatus { + return this.systemStatus.getValue(); + } +} + +interface SystemStatus { + cpu: number; + memory: number; + disk: number; + status: string; +} +``` + +Client usage: + +```typescript +const controller = client.controller('/status'); + +// Get current status immediately, then receive updates +const statusStream = await controller.getSystemStatus(); +console.log('Current status:', statusStream.getValue()); + +statusStream.subscribe(status => { + console.log('Status update:', status); +}); + +// Or just get current status once +const currentStatus = await controller.getCurrentStatus(); +console.log('One-time status:', currentStatus); +``` + +## Error Handling + +Errors in observables are automatically forwarded to the client: + +```typescript +@rpc.controller('/error-demo') +class ErrorDemoController { + @rpc.action() + errorStream(): Observable { + return new Observable(observer => { + observer.next('First value'); + observer.next('Second value'); + observer.error(new Error('Something went wrong!')); + }); + } +} +``` + +Client error handling: + +```typescript +const controller = client.controller('/error-demo'); +const stream = await controller.errorStream(); + +stream.subscribe({ + next: (value) => console.log('Received:', value), + error: (error) => console.error('Stream error:', error.message), + complete: () => console.log('Stream completed') +}); +``` + +## Automatic Cleanup + +When a client disconnects, all active subscriptions are automatically cleaned up on the server side. The teardown functions in your observables will be called, ensuring proper resource cleanup. + +## Performance Considerations + +- Use `BehaviorSubject` sparingly as it keeps the last value in memory +- Implement proper cleanup in your Observable teardown functions +- Consider using operators like `debounceTime` or `throttleTime` for high-frequency data +- Large objects in streams are automatically chunked for efficient transmission + +## Type Safety + +All streaming types are fully type-safe. The client receives the exact same types as defined on the server: + +```typescript +// Server +@rpc.action() +getTypedStream(): Observable<{ id: number, name: string }> { + // Implementation +} + +// Client - fully typed! +const stream = await controller.getTypedStream(); +stream.subscribe(data => { + console.log(data.id); // TypeScript knows this is a number + console.log(data.name); // TypeScript knows this is a string +}); +``` diff --git a/website/src/pages/documentation/rpc/transport.md b/website/src/pages/documentation/rpc/transport.md index a58ed8c35..b08ec93d0 100644 --- a/website/src/pages/documentation/rpc/transport.md +++ b/website/src/pages/documentation/rpc/transport.md @@ -1,17 +1,311 @@ # Transport Protocol -Deepkit RPC supports several transport protocols. WebSockets is the protocol that has the best compatibility (since browsers support it) while supporting all features like streaming. TCP is usually faster and is great for communication between servers (microservices) or non-browser clients. But WebSockets work well for server to server communication as well. +Deepkit RPC supports multiple transport protocols to accommodate different use cases and environments. Each transport has its own advantages and trade-offs. -## HTTP +## WebSockets -Deepkit's RPC HTTP protocol is a variant that is particularly easy to debug in the browser, as each function call is an HTTP request, but has its limitations such as no support for RxJS streaming. +WebSockets provide the best balance of compatibility and features, supporting all RPC capabilities including streaming, bidirectional communication, and real-time updates. -TODO: Not implemented yet. +### Server Setup -## WebSockets +```typescript +import { RpcWebSocketServer } from '@deepkit/rpc-tcp'; +import { RpcKernel } from '@deepkit/rpc'; + +const kernel = new RpcKernel(); +kernel.registerController(MyController, '/main'); + +const server = new RpcWebSocketServer(kernel, 'localhost:8081'); +server.start({ + host: '127.0.0.1', + port: 8081, +}); + +console.log('WebSocket server started at ws://127.0.0.1:8081'); +``` + +### Client Setup (Browser) + +```typescript +import { RpcWebSocketClient } from '@deepkit/rpc'; + +const client = new RpcWebSocketClient('ws://127.0.0.1:8081'); +await client.connect(); + +const controller = client.controller('/main'); +const result = await controller.myAction('test'); +``` + +### Client Setup (Node.js) -@deepkit/rpc-tcp `RpcWebSocketServer` and Browser WebSocket or Node `ws` package. +For Node.js environments, install the `ws` package: + +```bash +npm install ws +``` + +```typescript +import { RpcWebSocketClient } from '@deepkit/rpc'; +import ws from 'ws'; + +// Set WebSocket implementation for Node.js +global.WebSocket = ws as any; + +const client = new RpcWebSocketClient('ws://127.0.0.1:8081'); +await client.connect(); +``` + +### WebSocket Configuration + +```typescript +const server = new RpcWebSocketServer(kernel, 'localhost:8081', { + // Maximum message size (default: 16MB) + maxPayload: 16 * 1024 * 1024, + + // Compression settings + perMessageDeflate: { + threshold: 1024, + concurrencyLimit: 10, + memLevel: 7, + }, + + // Connection timeout + handshakeTimeout: 30000, +}); +``` ## TCP -@deepkit/rpc-tcp `RpcNetTcpServer` and `RpcNetTcpClientAdapter` +TCP transport provides the best performance for server-to-server communication and is ideal for microservices architectures. + +### Server Setup + +```typescript +import { RpcNetTcpServer } from '@deepkit/rpc-tcp'; + +const kernel = new RpcKernel(); +kernel.registerController(MyController, '/main'); + +const server = new RpcNetTcpServer(kernel); +server.start({ + host: '127.0.0.1', + port: 8082, +}); + +console.log('TCP server started at 127.0.0.1:8082'); +``` + +### Client Setup + +```typescript +import { RpcNetTcpClientAdapter } from '@deepkit/rpc-tcp'; +import { RpcClient } from '@deepkit/rpc'; + +const adapter = new RpcNetTcpClientAdapter('127.0.0.1:8082'); +const client = new RpcClient(adapter); + +await client.connect(); +const controller = client.controller('/main'); +``` + +### TCP Configuration + +```typescript +const server = new RpcNetTcpServer(kernel, { + // Keep-alive settings + keepAlive: true, + keepAliveInitialDelay: 30000, + + // No delay for small packets + noDelay: true, + + // Connection timeout + timeout: 60000, +}); +``` + +## HTTP + +HTTP transport is useful for debugging and simple request-response patterns, but has limitations with streaming and real-time features. + +### Server Setup + +```typescript +import { RpcHttpServer } from '@deepkit/rpc-tcp'; +import { createServer } from 'http'; + +const kernel = new RpcKernel(); +kernel.registerController(MyController, '/main'); + +const httpServer = createServer(); +const rpcServer = new RpcHttpServer(kernel); + +httpServer.on('request', (req, res) => { + if (req.url?.startsWith('/rpc')) { + rpcServer.handleRequest(req, res); + } else { + res.writeHead(404); + res.end('Not Found'); + } +}); + +httpServer.listen(8083, () => { + console.log('HTTP server started at http://127.0.0.1:8083'); +}); +``` + +### Client Setup + +```typescript +import { RpcHttpClientAdapter } from '@deepkit/rpc'; + +const adapter = new RpcHttpClientAdapter('http://127.0.0.1:8083/rpc'); +const client = new RpcClient(adapter); + +const controller = client.controller('/main'); +const result = await controller.myAction('test'); +``` + +### HTTP Limitations + +- No support for RxJS streaming (Observables, Subjects) +- No bidirectional communication +- No real-time updates +- Higher latency due to HTTP overhead +- Each action call is a separate HTTP request + +## Direct Client (Testing) + +For testing and development, use the DirectClient which bypasses network transport: + +```typescript +import { DirectClient } from '@deepkit/rpc'; + +const kernel = new RpcKernel(); +kernel.registerController(MyController, '/main'); + +const client = new DirectClient(kernel); +const controller = client.controller('/main'); + +// No network calls - direct kernel access +const result = await controller.myAction('test'); +``` + +## Transport Comparison + +| Feature | WebSockets | TCP | HTTP | Direct | +|---------|------------|-----|------|--------| +| Streaming (RxJS) | ✅ | ✅ | ❌ | ✅ | +| Bidirectional | ✅ | ✅ | ❌ | ✅ | +| Browser Support | ✅ | ❌ | ✅ | ❌ | +| Performance | Good | Excellent | Fair | Excellent | +| Debugging | Good | Fair | Excellent | Excellent | +| Real-time | ✅ | ✅ | ❌ | ✅ | +| Connection Pooling | ✅ | ✅ | ✅ | N/A | + +## Connection Management + +### Connection Events + +```typescript +const client = new RpcWebSocketClient('ws://127.0.0.1:8081'); + +client.onConnect.subscribe(() => { + console.log('Connected to server'); +}); + +client.onDisconnect.subscribe(() => { + console.log('Disconnected from server'); +}); + +client.onError.subscribe((error) => { + console.error('Connection error:', error); +}); + +await client.connect(); +``` + +### Automatic Reconnection + +```typescript +const client = new RpcWebSocketClient('ws://127.0.0.1:8081', { + // Enable automatic reconnection + autoReconnect: true, + + // Reconnection delay (ms) + reconnectDelay: 1000, + + // Maximum reconnection attempts + maxReconnectAttempts: 10, + + // Exponential backoff + reconnectBackoff: 1.5, +}); +``` + +### Security + +#### TLS/SSL Support + +```typescript +// WebSocket with TLS +const client = new RpcWebSocketClient('wss://secure.example.com:8081', { + // TLS options + rejectUnauthorized: true, + ca: fs.readFileSync('ca-cert.pem'), + cert: fs.readFileSync('client-cert.pem'), + key: fs.readFileSync('client-key.pem'), +}); + +// TCP with TLS +const adapter = new RpcNetTcpClientAdapter('secure.example.com:8082', { + tls: true, + rejectUnauthorized: true, + ca: fs.readFileSync('ca-cert.pem'), +}); +``` + +#### Authentication + +```typescript +const client = new RpcWebSocketClient('ws://127.0.0.1:8081'); + +// Set authentication token +client.token.set('your-auth-token'); + +// Or use custom authentication +client.authenticate = async () => { + const token = await getAuthToken(); + return token; +}; + +await client.connect(); +``` + +## Performance Tuning + +### Message Compression + +```typescript +// Enable compression for WebSocket +const server = new RpcWebSocketServer(kernel, 'localhost:8081', { + perMessageDeflate: { + threshold: 1024, // Compress messages > 1KB + concurrencyLimit: 10, // Max concurrent compressions + memLevel: 7, // Memory usage level (1-9) + windowBits: 13, // Compression window size + }, +}); +``` + +### Chunking Configuration + +```typescript +const client = new RpcWebSocketClient('ws://127.0.0.1:8081', { + // Chunk size for large messages (default: 64KB) + chunkSize: 128 * 1024, + + // Maximum message size before chunking + maxMessageSize: 16 * 1024 * 1024, +}); +``` From 0048a7551f33423cb066a1fb58242778d39c7b66 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 19:30:04 +0200 Subject: [PATCH 04/15] docs: improve http --- .../http/dependency-injection.md | 308 +++++++++ .../src/pages/documentation/http/events.md | 394 ++++++++++++ .../documentation/http/getting-started.md | 126 +++- .../pages/documentation/http/input-output.md | 229 +++++++ .../pages/documentation/http/middleware.md | 254 ++++++++ .../src/pages/documentation/http/security.md | 606 +++++++++++++++++- .../src/pages/documentation/http/testing.md | 352 ++++++++++ website/src/pages/documentation/http/views.md | 283 ++++++++ 8 files changed, 2548 insertions(+), 4 deletions(-) create mode 100644 website/src/pages/documentation/http/testing.md diff --git a/website/src/pages/documentation/http/dependency-injection.md b/website/src/pages/documentation/http/dependency-injection.md index 1ea4c3f26..2aa0f9075 100644 --- a/website/src/pages/documentation/http/dependency-injection.md +++ b/website/src/pages/documentation/http/dependency-injection.md @@ -65,3 +65,311 @@ router.get('/', (response: HttpResponse) => { It can be useful to place providers in the `http` scope, for example to instantiate services for each HTTP request. Once the HTTP request has been processed, the `http` scoped DI container is deleted, thus cleaning up all its provider instances from the garbage collector (GC). See [Dependency Injection Scopes](dependency-injection.md#di-scopes) to learn how to place providers in the `http` scope. + +## HTTP-Specific Providers + +### Request-Scoped Services + +HTTP-scoped providers are created for each request and automatically cleaned up when the request completes: + +```typescript +class UserSession { + private user?: User; + + setUser(user: User): void { + this.user = user; + } + + getUser(): User | undefined { + return this.user; + } +} + +class RequestLogger { + private logs: string[] = []; + + log(message: string): void { + this.logs.push(`[${new Date().toISOString()}] ${message}`); + } + + getLogs(): string[] { + return this.logs; + } +} + +const app = new App({ + providers: [ + { provide: UserSession, scope: 'http' }, + { provide: RequestLogger, scope: 'http' } + ], + controllers: [UserController] +}); +``` + +### Factory Providers with Request Data + +Create providers that depend on request information: + +```typescript +class User { + constructor(public id: number, public username: string) {} +} + +const app = new App({ + providers: [ + { + provide: User, + scope: 'http', + useFactory: (request: HttpRequest) => { + // Extract user from request (e.g., from JWT token) + const authHeader = request.headers.authorization; + if (authHeader) { + const token = authHeader.replace('Bearer ', ''); + const userData = decodeJWT(token); + return new User(userData.id, userData.username); + } + throw new HttpUnauthorizedError('No authentication provided'); + } + } + ] +}); +``` + +### Conditional Providers + +Provide different implementations based on request context: + +```typescript +interface DatabaseService { + query(sql: string): Promise; +} + +class ProductionDatabase implements DatabaseService { + async query(sql: string): Promise { + // Real database implementation + return []; + } +} + +class TestDatabase implements DatabaseService { + async query(sql: string): Promise { + // Mock implementation for testing + return [{ id: 1, name: 'Test Data' }]; + } +} + +const app = new App({ + providers: [ + { + provide: DatabaseService, + scope: 'http', + useFactory: (request: HttpRequest) => { + // Use test database for requests with test header + if (request.headers['x-test-mode'] === 'true') { + return new TestDatabase(); + } + return new ProductionDatabase(); + } + } + ] +}); +``` + +## Advanced Injection Patterns + +### Injecting Request Context + +Access request information in any service: + +```typescript +class AuditService { + constructor(private request: HttpRequest) {} + + logAction(action: string, data?: any): void { + console.log({ + timestamp: new Date().toISOString(), + action, + data, + ip: this.request.ip, + userAgent: this.request.headers['user-agent'], + url: this.request.url + }); + } +} + +class UserController { + @http.POST('/users') + createUser(userData: any, auditService: AuditService) { + // Create user logic + auditService.logAction('user_created', { username: userData.username }); + return { success: true }; + } +} +``` + +### Multi-Provider Pattern + +Provide multiple implementations and choose at runtime: + +```typescript +interface NotificationService { + send(message: string): Promise; +} + +class EmailNotificationService implements NotificationService { + async send(message: string): Promise { + console.log('Sending email:', message); + } +} + +class SMSNotificationService implements NotificationService { + async send(message: string): Promise { + console.log('Sending SMS:', message); + } +} + +class NotificationManager { + constructor( + private emailService: EmailNotificationService, + private smsService: SMSNotificationService, + private request: HttpRequest + ) {} + + async notify(message: string): Promise { + // Choose notification method based on request + const method = this.request.headers['x-notification-method']; + + if (method === 'sms') { + await this.smsService.send(message); + } else { + await this.emailService.send(message); + } + } +} + +const app = new App({ + providers: [ + EmailNotificationService, + SMSNotificationService, + { provide: NotificationManager, scope: 'http' } + ] +}); +``` + +### Injector Context Pattern + +Use the injector context to set and retrieve typed services during the request lifecycle: + +```typescript +class UserContext { + constructor( + public user: User, + public permissions: string[] = [] + ) {} + + hasPermission(permission: string): boolean { + return this.permissions.includes(permission); + } +} + +class RequestStore { + // This will be set by the authentication system + constructor() { + throw new Error('RequestStore must be set via injector context during authentication'); + } +} + +const app = new App({ + providers: [ + { + provide: UserContext, + scope: 'http', + useFactory: () => { + throw new Error('UserContext must be set via injector context during authentication'); + } + }, + { + provide: RequestStore, + scope: 'http', + useFactory: () => { + throw new Error('RequestStore must be set via injector context during authentication'); + } + } + ] +}); + +// In an authentication event listener +app.listen(httpWorkflow.onAuth, (event) => { + const user = authenticateUser(event.request); + const permissions = getUserPermissions(user); + + // Set the user context via injector context + const userContext = new UserContext(user, permissions); + event.injectorContext.set(UserContext, userContext); + + // Set custom request store + const requestStore = new RequestStore(); + // ... initialize request store with data + event.injectorContext.set(RequestStore, requestStore); +}); + +// In a controller - now you can inject the typed services +class SecureController { + @http.GET('/profile') + getProfile(userContext: UserContext) { + return { + user: userContext.user, + permissions: userContext.permissions, + canEdit: userContext.hasPermission('edit_profile') + }; + } + + @http.GET('/admin') + adminPanel(userContext: UserContext, requestStore: RequestStore) { + if (!userContext.hasPermission('admin_access')) { + throw new HttpAccessDeniedError('Admin access required'); + } + + return { message: 'Welcome to admin panel' }; + } +} +``` + +## Testing with Dependency Injection + +Mock services for testing: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +class MockUserService { + async getUser(id: number): Promise { + return new User(id, `Test User ${id}`); + } +} + +test('controller with mocked dependencies', async () => { + const testing = createTestingApp({ + controllers: [UserController], + providers: [ + { provide: UserService, useClass: MockUserService } + ] + }); + + const response = await testing.request(HttpRequest.GET('/users/1')); + expect(response.json.name).toBe('Test User 1'); +}); +``` + +## Best Practices + +1. **Use HTTP scope for request-specific data**: Services that need request context +2. **Factory providers with error throwing**: Define providers that throw errors and set them via injector context +3. **Use injector context for runtime data**: Set providers during request lifecycle using `event.injectorContext.set()` +4. **Keep providers focused**: Each provider should have a single responsibility +5. **Initialize providers in events**: Use `onRequest` or `onAuth` events to set up request-scoped providers +6. **Mock dependencies in tests**: Use dependency injection for testability +7. **Avoid memory leaks**: HTTP-scoped providers are automatically cleaned up +8. **Type safety**: Use TypeScript interfaces for better type checking +9. **Document dependencies**: Make clear what each provider expects and provides +10. **Consistent error messages**: Use descriptive error messages in factory functions diff --git a/website/src/pages/documentation/http/events.md b/website/src/pages/documentation/http/events.md index e1440a76e..aa824a37d 100644 --- a/website/src/pages/documentation/http/events.md +++ b/website/src/pages/documentation/http/events.md @@ -82,3 +82,397 @@ Welcome $ curl http://localhost:8080/admin No access to this area ``` + +## Advanced Event Examples + +### Authentication with Events + +```typescript +import { HttpQuery, HttpHeader, HttpPath } from '@deepkit/http'; + +class UserSession { + constructor(public userId?: number, public username?: string) {} + + isAuthenticated(): boolean { + return !!this.userId; + } +} + +const app = new App({ + providers: [ + { + provide: UserSession, + scope: 'http', + useFactory: () => { + throw new Error('UserSession must be set via injector context'); + } + } + ] +}); + +// Initialize session for each request +app.listen(httpWorkflow.onRequest, (event) => { + const session = new UserSession(); + event.injectorContext.set(UserSession, session); +}); + +// Authentication using query parameters +app.listen(httpWorkflow.onAuth, (event, session: UserSession, auth: HttpQuery) => { + const validTokens = { + 'token123': { userId: 1, username: 'john' }, + 'token456': { userId: 2, username: 'jane' } + }; + + const userData = validTokens[auth]; + if (userData) { + session.userId = userData.userId; + session.username = userData.username; + // Update the session in injector context + event.injectorContext.set(UserSession, session); + } +}); + +// Authorization check in controller event +app.listen(httpWorkflow.onController, (event, session: UserSession) => { + if (event.route.groups.includes('authenticated')) { + if (!session.isAuthenticated()) { + event.accessDenied(); + } + } +}); +``` + +### Parameter Injection in Events + +```typescript +// Access path parameters in events +app.listen(httpWorkflow.onController, (event, groupId: HttpPath, authorization?: HttpHeader) => { + // groupId is automatically extracted from the route path + // authorization is extracted from headers + + if (groupId > 100) { + if (authorization !== 'secretToken') { + throw new HttpUnauthorizedError('Not authorized for this group'); + } + } +}); +``` + +### Body and Query Parameter Access + +```typescript +interface AuthData { + auth: string; + userId: number; +} + +class AuthenticatedUser { + constructor(public auth: string, public userId: number) {} +} + +const app = new App({ + providers: [ + { + provide: AuthenticatedUser, + scope: 'http', + useFactory: () => { + throw new Error('AuthenticatedUser must be set via injector context during authentication'); + } + } + ] +}); + +// Access both body and query parameters +app.listen(httpWorkflow.onAuth, (event, body: HttpBody, queries: HttpQueries) => { + // For POST requests, auth comes from body + // For GET requests, auth comes from query parameters + const auth = body?.auth || queries.auth; + const userId = body?.userId || parseInt(queries.userId as string); + + if (auth && userId) { + const authenticatedUser = new AuthenticatedUser(auth, userId); + event.injectorContext.set(AuthenticatedUser, authenticatedUser); + } +}); +``` + +### Custom Request Handling + +```typescript +// Handle CORS preflight requests +app.listen(httpWorkflow.onRouteNotFound, (event) => { + if (event.request.method === 'OPTIONS') { + event.send(new JSONResponse(true, 200)); + } +}); + +// Custom 404 handling +app.listen(httpWorkflow.onRouteNotFound, (event) => { + if (!event.sent) { + event.send(new HtmlResponse(` + + +

Page Not Found

+

The requested page ${event.request.url} was not found.

+ + + `, 404)); + } +}); +``` + +### Error Handling Events + +```typescript +// Global error handler +app.listen(httpWorkflow.onControllerError, (event) => { + const error = event.error; + + console.error('Controller error:', error); + + // Custom error responses based on error type + if (error instanceof ValidationError) { + event.send(new JSONResponse({ + error: 'Validation failed', + details: error.errors + }, 400)); + } else if (error instanceof HttpUnauthorizedError) { + event.send(new JSONResponse({ + error: 'Authentication required' + }, 401)); + } else { + // Generic error response + event.send(new JSONResponse({ + error: 'Internal server error' + }, 500)); + } +}); + +// Parameter resolution error handling +app.listen(httpWorkflow.onParametersFailed, (event) => { + console.error('Parameter resolution failed:', event.error); + + event.send(new JSONResponse({ + error: 'Invalid request parameters', + message: event.error.message + }, 400)); +}); +``` + +### Response Modification + +```typescript +// Modify all responses +app.listen(httpWorkflow.onResponse, (event) => { + // Add custom headers to all responses + event.response.setHeader('X-API-Version', '1.0'); + event.response.setHeader('X-Request-ID', generateRequestId()); + + // Add CORS headers + event.response.setHeader('Access-Control-Allow-Origin', '*'); +}); + +// Transform response data +app.listen(httpWorkflow.onResponse, (event) => { + if (event.result && typeof event.result === 'object') { + // Wrap all responses in a standard format + const wrappedResult = { + success: true, + data: event.result, + timestamp: new Date().toISOString() + }; + + event.send(new JSONResponse(wrappedResult)); + } +}); +``` + +### Session Management with Events + +```typescript +class HttpSession { + private data = new Map(); + + set(key: string, value: any): void { + this.data.set(key, value); + } + + get(key: string): T | undefined { + return this.data.get(key); + } +} + +class SessionUser { + constructor(public id: number, public username: string) {} +} + +const app = new App({ + providers: [ + { + provide: HttpSession, + scope: 'http', + useFactory: () => { + throw new Error('HttpSession must be initialized via injector context'); + } + }, + { + provide: SessionUser, + scope: 'http', + useFactory: () => { + throw new Error('SessionUser must be set via injector context during authentication'); + } + } + ] +}); + +// Session initialization +app.listen(httpWorkflow.onRequest, (event) => { + const session = new HttpSession(); + // Initialize session data from cookies or headers + const sessionId = event.request.headers['x-session-id']; + if (sessionId) { + // Load session data from storage + session.set('sessionId', sessionId); + } + event.injectorContext.set(HttpSession, session); +}); + +// Session-based authentication +app.listen(httpWorkflow.onAuth, (event, session: HttpSession) => { + const userData = session.get('user'); + if (userData) { + const user = new SessionUser(userData.id, userData.username); + event.injectorContext.set(SessionUser, user); + } +}); +``` + +### Request Validation Events + +```typescript +// Custom request validation +app.listen(httpWorkflow.onResolveParameters, (event) => { + // Validate request size + const contentLength = parseInt(event.request.headers['content-length'] || '0'); + if (contentLength > 10 * 1024 * 1024) { // 10MB limit + throw new HttpBadRequestError('Request too large'); + } + + // Validate content type for POST/PUT requests + if (['POST', 'PUT'].includes(event.request.method)) { + const contentType = event.request.headers['content-type']; + if (!contentType || !contentType.includes('application/json')) { + throw new HttpBadRequestError('Content-Type must be application/json'); + } + } +}); +``` + +### Logging and Monitoring + +```typescript +// Request logging +app.listen(httpWorkflow.onRequest, (event) => { + const startTime = Date.now(); + event.request.store.startTime = startTime; + + console.log(`[${new Date().toISOString()}] ${event.request.method} ${event.request.url} - Started`); +}); + +// Response logging +app.listen(httpWorkflow.onResponse, (event) => { + const startTime = event.request.store.startTime; + const duration = Date.now() - startTime; + + console.log(`[${new Date().toISOString()}] ${event.request.method} ${event.request.url} - ${event.response.statusCode} - ${duration}ms`); +}); + +// Error logging +app.listen(httpWorkflow.onControllerError, (event) => { + console.error(`[${new Date().toISOString()}] Error in ${event.request.method} ${event.request.url}:`, event.error); +}); +``` + +### Event Priority and Execution Order + +```typescript +// High priority listener (runs first) +app.listen(httpWorkflow.onController, (event) => { + console.log('High priority listener'); +}, 200); + +// Default priority listener +app.listen(httpWorkflow.onController, (event) => { + console.log('Default priority listener'); +}); + +// Low priority listener (runs last) +app.listen(httpWorkflow.onController, (event) => { + console.log('Low priority listener'); +}, -100); +``` + +### Conditional Event Handling + +```typescript +// Only handle specific routes +app.listen(httpWorkflow.onController, (event) => { + if (event.route.path.startsWith('/api/')) { + // API-specific logic + event.response.setHeader('X-API-Version', '2.0'); + } +}); + +// Handle based on HTTP method +app.listen(httpWorkflow.onController, (event) => { + if (event.request.method === 'POST') { + // POST-specific validation + console.log('Processing POST request'); + } +}); + +// Handle based on route groups +app.listen(httpWorkflow.onController, (event) => { + if (event.route.groups.includes('admin')) { + // Admin-specific security checks + console.log('Admin route accessed'); + } +}); +``` + +### Event Data Access + +```typescript +// Access all available data in events +app.listen(httpWorkflow.onController, (event) => { + console.log('Route info:', { + path: event.route.path, + method: event.route.httpMethod, + groups: event.route.groups, + name: event.route.name + }); + + console.log('Request info:', { + method: event.request.method, + url: event.request.url, + headers: event.request.headers, + ip: event.request.ip + }); + + console.log('Controller info:', { + className: event.controllerClass.name, + methodName: event.methodName + }); +}); +``` + +## Event Best Practices + +1. **Use appropriate events**: Choose the right event for your use case +2. **Handle errors gracefully**: Always wrap event handlers in try-catch +3. **Consider performance**: Avoid heavy operations in frequently called events +4. **Use priority wisely**: Set appropriate priorities for event execution order +5. **Keep handlers focused**: Each event handler should have a single responsibility +6. **Test event handlers**: Write unit tests for complex event logic +7. **Document event dependencies**: Make clear what data your events expect +8. **Use dependency injection**: Inject services into event handlers when needed diff --git a/website/src/pages/documentation/http/getting-started.md b/website/src/pages/documentation/http/getting-started.md index 46efe7946..195fcdc3f 100644 --- a/website/src/pages/documentation/http/getting-started.md +++ b/website/src/pages/documentation/http/getting-started.md @@ -171,9 +171,62 @@ new Server( }); ``` +## Testing + +Deepkit HTTP provides comprehensive testing utilities that allow you to test your HTTP applications without starting an actual server: + +```typescript +import { expect, test } from '@jest/globals'; +import { createTestingApp } from '@deepkit/framework'; +import { HttpRequest } from '@deepkit/http'; + +class UserController { + @http.GET('/users/:id') + getUser(id: number) { + return { id, name: `User ${id}` }; + } +} + +test('user controller', async () => { + const testing = createTestingApp({ + controllers: [UserController] + }); + + const response = await testing.request(HttpRequest.GET('/users/42')); + expect(response.statusCode).toBe(200); + expect(response.json).toEqual({ id: 42, name: 'User 42' }); +}); +``` + +For more comprehensive testing examples, see the [Testing](testing.md) documentation. + ## HTTP Client -todo: fetch API, validation, und cast. +Deepkit HTTP provides utilities for making HTTP requests with automatic validation and type casting: + +```typescript +import { HttpRequest } from '@deepkit/http'; + +// Create requests with fluent API +const request = HttpRequest.POST('/api/users') + .json({ name: 'John', email: 'john@example.com' }) + .header('Authorization', 'Bearer token123') + .build(); + +// For file uploads +const uploadRequest = HttpRequest.POST('/upload') + .multiPart([ + { + name: 'file', + file: Buffer.from('file content'), + fileName: 'document.txt' + }, + { + name: 'description', + value: 'Important document' + } + ]); +``` ## Route Names @@ -205,7 +258,74 @@ const router = app.get(HttpRouter); router.resolveUrl('userDetail', {id: 2}); //=> '/user/2' ``` -## Security +## Error Handling + +Deepkit HTTP provides built-in error classes for common HTTP errors: + +```typescript +import { + HttpBadRequestError, + HttpUnauthorizedError, + HttpNotFoundError, + HttpAccessDeniedError +} from '@deepkit/http'; + +class UserController { + @http.GET('/users/:id') + getUser(id: number) { + if (id <= 0) { + throw new HttpBadRequestError('Invalid user ID'); + } + + const user = findUser(id); + if (!user) { + throw new HttpNotFoundError('User not found'); + } + + return user; + } + + @http.DELETE('/users/:id') + deleteUser(id: number, currentUser: User) { + if (!currentUser) { + throw new HttpUnauthorizedError('Authentication required'); + } + + if (!currentUser.canDelete(id)) { + throw new HttpAccessDeniedError('Insufficient permissions'); + } + + deleteUser(id); + return { success: true }; + } +} +``` + +## Configuration + +Configure HTTP behavior through the HttpConfig: + +```typescript +import { HttpConfig } from '@deepkit/http'; + +const httpConfig = new HttpConfig(); +httpConfig.port = 3000; +httpConfig.host = '0.0.0.0'; +httpConfig.parser.multipartJsonKey = 'json'; +httpConfig.parser.maxFileSize = 10 * 1024 * 1024; // 10MB + +const app = new App({ + config: { http: httpConfig }, + imports: [new FrameworkModule] +}); +``` + +## Next Steps -## Sessions +- **[Input & Output](input-output.md)**: Learn about handling request data and responses +- **[Security](security.md)**: Implement authentication, authorization, and security best practices +- **[Middleware](middleware.md)**: Add custom middleware for cross-cutting concerns +- **[Events](events.md)**: Hook into the HTTP request lifecycle with events +- **[Testing](testing.md)**: Write comprehensive tests for your HTTP applications +- **[Dependency Injection](dependency-injection.md)**: Use DI for better code organization diff --git a/website/src/pages/documentation/http/input-output.md b/website/src/pages/documentation/http/input-output.md index 0298cfa46..ab96beee3 100644 --- a/website/src/pages/documentation/http/input-output.md +++ b/website/src/pages/documentation/http/input-output.md @@ -482,3 +482,232 @@ router.add( The `User` object does not necessarily have to depend on a parameter. It could just as well depend on a session or an HTTP header, and only be provided when the user is logged in. In `RouteParameterResolverContext` a lot of information about the HTTP request is available, so that many use cases can be mapped. In principle, it is also possible to have complex parameter types provided via the Dependency Injection container from the `http` scope, since these are also available in the route function or method. However, this has the disadvantage that no asynchronous function calls can be used, since the DI container is synchronous throughout. + +## File Uploads + +Deepkit HTTP provides comprehensive support for file uploads through multipart form data. Files are automatically parsed and made available as `UploadedFile` objects. + +### Single File Upload + +```typescript +import { UploadedFile } from '@deepkit/http'; + +router.post('/upload', (file: UploadedFile) => { + return { + name: file.name, + size: file.size, + type: file.type, + path: file.path + }; +}); +``` + +### Multiple Files Upload + +```typescript +interface UploadData { + files: UploadedFile[]; + description: string; +} + +router.post('/upload-multiple', (body: HttpBody) => { + return { + uploadedCount: body.files.length, + description: body.description, + files: body.files.map(f => ({ + name: f.name, + size: f.size + })) + }; +}); +``` + +### Mixed Form Data with Files + +```typescript +interface FormData { + profilePicture: UploadedFile; + name: string; + age: number; + tags: string[]; +} + +router.post('/profile', (body: HttpBody) => { + return { + message: 'Profile updated', + user: { + name: body.name, + age: body.age, + tags: body.tags + }, + uploadedFile: { + name: body.profilePicture.name, + size: body.profilePicture.size + } + }; +}); +``` + +### File Upload Validation + +```typescript +import { MaxLength, MinLength } from '@deepkit/type'; + +interface FileUploadRequest { + file: UploadedFile; + title: string & MinLength<3> & MaxLength<100>; + category: 'image' | 'document' | 'video'; +} + +router.post('/upload-validated', (body: HttpBody) => { + // Validate file type + if (body.category === 'image' && !body.file.type.startsWith('image/')) { + throw new HttpBadRequestError('File must be an image'); + } + + // Validate file size (example: max 5MB) + if (body.file.size > 5 * 1024 * 1024) { + throw new HttpBadRequestError('File too large'); + } + + return { + message: 'File uploaded successfully', + file: { + name: body.file.name, + size: body.file.size, + type: body.file.type + } + }; +}); +``` + +### File Processing + +```typescript +import { promises as fs } from 'fs'; +import path from 'path'; + +router.post('/process-file', async (file: UploadedFile) => { + // Read file content + const content = await fs.readFile(file.path); + + // Process the file (example: save to permanent location) + const permanentPath = path.join('/uploads', file.name); + await fs.copyFile(file.path, permanentPath); + + // Clean up temporary file + await fs.unlink(file.path); + + return { + message: 'File processed', + originalSize: file.size, + savedPath: permanentPath + }; +}); +``` + +## Advanced Response Types + +Deepkit HTTP supports various response types beyond simple JSON responses. + +### HTML Responses + +```typescript +import { HtmlResponse } from '@deepkit/http'; + +router.get('/page', () => { + return new HtmlResponse(` + + +

Welcome

+

This is an HTML response

+ + + `); +}); +``` + +### Custom Response Headers + +```typescript +import { Response } from '@deepkit/http'; + +router.get('/download', () => { + const response = new Response('File content here', 200); + response.setHeader('Content-Type', 'application/octet-stream'); + response.setHeader('Content-Disposition', 'attachment; filename="file.txt"'); + return response; +}); +``` + +### Streaming Responses + +```typescript +import { Readable } from 'stream'; + +router.get('/stream', (response: HttpResponse) => { + response.setHeader('Content-Type', 'text/plain'); + + const stream = new Readable({ + read() { + this.push(`Data chunk ${Date.now()}\n`); + } + }); + + stream.pipe(response); +}); +``` + +### JSON Response with Custom Status + +```typescript +import { JSONResponse } from '@deepkit/http'; + +router.post('/create', (data: any) => { + // Create resource logic here + + return new JSONResponse({ + message: 'Resource created', + id: 123 + }, 201); // HTTP 201 Created +}); +``` + +## Error Responses + +Proper error handling with appropriate HTTP status codes: + +```typescript +import { + HttpBadRequestError, + HttpNotFoundError, + HttpUnauthorizedError, + HttpAccessDeniedError +} from '@deepkit/http'; + +router.get('/user/:id', (id: number) => { + if (id <= 0) { + throw new HttpBadRequestError('Invalid user ID'); + } + + const user = findUser(id); + if (!user) { + throw new HttpNotFoundError('User not found'); + } + + return user; +}); + +router.delete('/user/:id', (id: number, currentUser: User) => { + if (!currentUser) { + throw new HttpUnauthorizedError('Authentication required'); + } + + if (!currentUser.canDeleteUser(id)) { + throw new HttpAccessDeniedError('Insufficient permissions'); + } + + deleteUser(id); + return { message: 'User deleted' }; +}); +``` diff --git a/website/src/pages/documentation/http/middleware.md b/website/src/pages/documentation/http/middleware.md index afa867adf..131ef3e4f 100644 --- a/website/src/pages/documentation/http/middleware.md +++ b/website/src/pages/documentation/http/middleware.md @@ -262,6 +262,260 @@ const ApiModule = new AppModule({}, { }); ``` +## Advanced Middleware Patterns +### Async Middleware +Middleware can be asynchronous and perform complex operations: +```typescript +class DatabaseMiddleware implements HttpMiddleware { + constructor(private database: Database) {} + + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + try { + // Perform async database operation + const user = await this.database.getUserFromSession(request); + request.store.user = user; + next(); + } catch (error) { + next(error); + } + } +} +``` + +### Error Handling Middleware + +```typescript +class ErrorHandlingMiddleware implements HttpMiddleware { + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + try { + next(); + } catch (error) { + console.error('Request error:', error); + + if (!response.headersSent) { + response.statusCode = 500; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ + error: 'Internal Server Error', + message: process.env.NODE_ENV === 'development' ? error.message : undefined + })); + } + } + } +} +``` + +### Request Logging Middleware + +```typescript +class RequestLoggingMiddleware implements HttpMiddleware { + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + const startTime = Date.now(); + + console.log(`${request.method} ${request.url} - Started`); + + // Override response.end to log completion + const originalEnd = response.end.bind(response); + response.end = function(chunk?: any) { + const duration = Date.now() - startTime; + console.log(`${request.method} ${request.url} - ${response.statusCode} - ${duration}ms`); + return originalEnd(chunk); + }; + + next(); + } +} +``` + +### Authentication Middleware + +```typescript +class AuthenticationMiddleware implements HttpMiddleware { + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + const authHeader = request.headers.authorization; + + if (!authHeader) { + response.statusCode = 401; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ error: 'Authentication required' })); + return; + } + + try { + const token = authHeader.replace('Bearer ', ''); + const user = await this.validateToken(token); + request.store.user = user; + next(); + } catch (error) { + response.statusCode = 401; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ error: 'Invalid token' })); + } + } + + private async validateToken(token: string): Promise { + // Implement token validation logic + throw new Error('Not implemented'); + } +} +``` + +### Rate Limiting Middleware + +```typescript +class RateLimitingMiddleware implements HttpMiddleware { + private requests = new Map(); + private maxRequests = 100; + private windowMs = 15 * 60 * 1000; // 15 minutes + + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + const clientIp = request.ip || request.headers['x-forwarded-for'] as string; + const now = Date.now(); + + const userRequests = this.requests.get(clientIp); + + if (!userRequests || now > userRequests.resetTime) { + this.requests.set(clientIp, { count: 1, resetTime: now + this.windowMs }); + next(); + return; + } + + if (userRequests.count >= this.maxRequests) { + response.statusCode = 429; + response.setHeader('Content-Type', 'application/json'); + response.setHeader('Retry-After', Math.ceil((userRequests.resetTime - now) / 1000).toString()); + response.end(JSON.stringify({ error: 'Too many requests' })); + return; + } + + userRequests.count++; + next(); + } +} +``` + +### CORS Middleware + +```typescript +class CorsMiddleware implements HttpMiddleware { + private allowedOrigins = ['http://localhost:3000', 'https://myapp.com']; + + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + const origin = request.headers.origin; + + if (origin && this.allowedOrigins.includes(origin)) { + response.setHeader('Access-Control-Allow-Origin', origin); + } + + response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + response.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + response.setHeader('Access-Control-Allow-Credentials', 'true'); + + if (request.method === 'OPTIONS') { + response.statusCode = 200; + response.end(); + return; + } + + next(); + } +} +``` + +## Middleware Ordering + +The order of middleware execution is important. Middleware is executed in the order it's registered: + +```typescript +new App({ + middlewares: [ + httpMiddleware.for(CorsMiddleware), // 1. Handle CORS first + httpMiddleware.for(RequestLoggingMiddleware), // 2. Log requests + httpMiddleware.for(RateLimitingMiddleware), // 3. Rate limiting + httpMiddleware.for(AuthenticationMiddleware), // 4. Authentication + httpMiddleware.for(ErrorHandlingMiddleware), // 5. Error handling last + ], + imports: [new FrameworkModule] +}); +``` + +## Conditional Middleware + +Apply middleware only to specific routes or conditions: + +```typescript +class ConditionalMiddleware implements HttpMiddleware { + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + // Only apply to API routes + if (request.url.startsWith('/api/')) { + // Apply middleware logic + console.log('API request:', request.url); + } + + next(); + } +} + +// Or use route-specific middleware +new App({ + middlewares: [ + httpMiddleware.for(AuthenticationMiddleware).forRoutes({ path: '/admin/*' }), + httpMiddleware.for(RateLimitingMiddleware).forRoutes({ group: 'api' }), + ], +}); +``` + +## Testing Middleware + +```typescript +import { expect, test } from '@jest/globals'; +import { createTestingApp } from '@deepkit/framework'; + +test('middleware execution', async () => { + const logs: string[] = []; + + class TestMiddleware implements HttpMiddleware { + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + logs.push(`Before: ${request.method} ${request.url}`); + next(); + logs.push(`After: ${request.method} ${request.url}`); + } + } + + class TestController { + @http.GET('/test') + test() { + logs.push('Controller executed'); + return 'success'; + } + } + + const testing = createTestingApp({ + controllers: [TestController], + providers: [TestMiddleware], + middlewares: [httpMiddleware.for(TestMiddleware)] + }); + + await testing.request(HttpRequest.GET('/test')); + + expect(logs).toEqual([ + 'Before: GET /test', + 'Controller executed', + 'After: GET /test' + ]); +}); +``` + +## Best Practices + +1. **Keep middleware focused**: Each middleware should have a single responsibility +2. **Handle errors properly**: Always use try-catch in async middleware +3. **Call next()**: Always call next() to continue the middleware chain +4. **Order matters**: Place middleware in logical order (CORS first, error handling last) +5. **Use dependency injection**: Inject services into middleware constructors +6. **Test middleware**: Write unit tests for middleware logic +7. **Performance considerations**: Avoid heavy operations in frequently called middleware +8. **Conditional application**: Use route filters to apply middleware selectively diff --git a/website/src/pages/documentation/http/security.md b/website/src/pages/documentation/http/security.md index d63cd6d21..1f9e197a2 100644 --- a/website/src/pages/documentation/http/security.md +++ b/website/src/pages/documentation/http/security.md @@ -1,3 +1,607 @@ # Security -This section is still under construction. +Deepkit HTTP provides comprehensive security features including authentication, authorization, session management, and protection against common web vulnerabilities. This chapter covers how to implement secure HTTP applications. + +## Authentication + +Authentication verifies the identity of users accessing your application. Deepkit HTTP supports various authentication methods through its event system and dependency injection. + +### Basic Authentication with Headers + +You can implement authentication by listening to HTTP workflow events and checking authentication headers: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; +import { http, httpWorkflow, HttpUnauthorizedError, HttpRequest } from '@deepkit/http'; +import { eventDispatcher } from '@deepkit/event'; + +class User { + constructor(public username: string, public id: number) {} +} + +class UserController { + @http.GET('/profile') + getProfile(user: User) { + return { username: user.username, id: user.id }; + } +} + +const app = new App({ + controllers: [UserController], + imports: [new FrameworkModule], + providers: [ + { + provide: User, + scope: 'http', + useFactory: () => { + throw new Error('User must be set via injector context during authentication'); + } + } + ] +}); + +// Authentication listener +app.listen(httpWorkflow.onAuth, (event) => { + const authHeader = event.request.headers.authorization; + + if (!authHeader) { + throw new HttpUnauthorizedError('Authorization header required'); + } + + // Validate token (this is a simple example) + const validTokens = { + 'token123': { username: 'john', id: 1 }, + 'token456': { username: 'jane', id: 2 } + }; + + const userData = validTokens[authHeader]; + if (!userData) { + throw new HttpUnauthorizedError('Invalid token'); + } + + // Set user via injector context + const user = new User(userData.username, userData.id); + event.injectorContext.set(User, user); +}); + +app.run(); +``` + +### JWT Authentication + +For JWT-based authentication, you can create a more sophisticated authentication system: + +```typescript +import jwt from 'jsonwebtoken'; + +interface JWTPayload { + userId: number; + username: string; + exp: number; +} + +class AuthService { + private secretKey = 'your-secret-key'; + + verifyToken(token: string): JWTPayload { + try { + return jwt.verify(token, this.secretKey) as JWTPayload; + } catch (error) { + throw new HttpUnauthorizedError('Invalid or expired token'); + } + } + + generateToken(user: User): string { + return jwt.sign( + { userId: user.id, username: user.username }, + this.secretKey, + { expiresIn: '24h' } + ); + } +} + +// JWT Authentication listener +app.listen(httpWorkflow.onAuth, (event, authService: AuthService) => { + const authHeader = event.request.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new HttpUnauthorizedError('Bearer token required'); + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + const payload = authService.verifyToken(token); + + const user = new User(payload.username, payload.userId); + event.injectorContext.set(User, user); +}); +``` + +## Authorization + +Authorization determines what authenticated users are allowed to do. Deepkit HTTP provides several ways to implement authorization. + +### Role-Based Access Control + +```typescript +class User { + constructor( + public username: string, + public id: number, + public roles: string[] = [] + ) {} + + hasRole(role: string): boolean { + return this.roles.includes(role); + } +} + +class AdminController { + @http.GET('/admin/users').group('admin') + listUsers() { + return { users: ['john', 'jane'] }; + } + + @http.DELETE('/admin/users/:id').group('admin') + deleteUser(id: number) { + return { deleted: id }; + } +} + +// Authorization listener +app.listen(httpWorkflow.onController, (event, user: User) => { + // Check if route requires admin access + if (event.route.groups.includes('admin')) { + if (!user || !user.hasRole('admin')) { + event.accessDenied(); + return; + } + } +}); +``` + +### Permission-Based Authorization + +```typescript +interface Permission { + resource: string; + action: string; +} + +class User { + constructor( + public username: string, + public id: number, + public permissions: Permission[] = [] + ) {} + + hasPermission(resource: string, action: string): boolean { + return this.permissions.some(p => + p.resource === resource && p.action === action + ); + } +} + +class UserController { + @http.GET('/users').permission('users', 'read') + listUsers() { + return { users: [] }; + } + + @http.POST('/users').permission('users', 'create') + createUser(userData: any) { + return { created: true }; + } +} + +// Custom decorator for permissions +function permission(resource: string, action: string) { + return (target: any, propertyKey: string) => { + // Store permission metadata + Reflect.defineMetadata('permission', { resource, action }, target, propertyKey); + }; +} + +// Permission-based authorization listener +app.listen(httpWorkflow.onController, (event, user: User) => { + const permission = Reflect.getMetadata('permission', event.controllerClass.prototype, event.methodName); + + if (permission) { + if (!user || !user.hasPermission(permission.resource, permission.action)) { + event.accessDenied(); + return; + } + } +}); +``` + +## Sessions + +Sessions provide a way to store user data across multiple HTTP requests. Deepkit HTTP supports session management through HTTP-scoped providers. + +### Basic Session Implementation + +```typescript +class HttpSession { + private data: Map = new Map(); + + set(key: string, value: any): void { + this.data.set(key, value); + } + + get(key: string): T | undefined { + return this.data.get(key); + } + + has(key: string): boolean { + return this.data.has(key); + } + + delete(key: string): void { + this.data.delete(key); + } + + clear(): void { + this.data.clear(); + } +} + +class SessionController { + @http.POST('/login') + login(credentials: { username: string, password: string }, session: HttpSession) { + // Validate credentials (simplified) + if (credentials.username === 'admin' && credentials.password === 'secret') { + session.set('user', { username: credentials.username, loggedIn: true }); + return { success: true }; + } + throw new HttpUnauthorizedError('Invalid credentials'); + } + + @http.GET('/profile') + getProfile(session: HttpSession) { + const user = session.get('user'); + if (!user || !user.loggedIn) { + throw new HttpUnauthorizedError('Not logged in'); + } + return user; + } + + @http.POST('/logout') + logout(session: HttpSession) { + session.clear(); + return { success: true }; + } +} + +const app = new App({ + controllers: [SessionController], + providers: [ + { + provide: HttpSession, + scope: 'http', + useFactory: () => { + throw new Error('HttpSession must be initialized via injector context'); + } + } + ], + imports: [new FrameworkModule] +}); + +// Initialize session for each request +app.listen(httpWorkflow.onRequest, (event) => { + const session = new HttpSession(); + // Load session data from cookies/storage if needed + event.injectorContext.set(HttpSession, session); +}); +``` + +### User Session with Authentication + +```typescript +class UserSession { + private user?: User; + + setUser(user: User): void { + this.user = user; + } + + getUser(): User { + if (!this.user) { + throw new HttpUnauthorizedError('Not authenticated'); + } + return this.user; + } + + isAuthenticated(): boolean { + return !!this.user; + } + + clear(): void { + this.user = undefined; + } +} + +class AuthController { + @http.POST('/auth/login') + async login( + credentials: { username: string, password: string }, + session: UserSession + ) { + // Validate credentials against database + const user = await this.validateCredentials(credentials); + if (!user) { + throw new HttpUnauthorizedError('Invalid credentials'); + } + + session.setUser(user); + return { success: true, user: { id: user.id, username: user.username } }; + } + + @http.GET('/auth/me') + getCurrentUser(session: UserSession) { + const user = session.getUser(); + return { id: user.id, username: user.username }; + } + + private async validateCredentials(credentials: any): Promise { + // Implement your credential validation logic + return null; + } +} + +const app = new App({ + controllers: [AuthController], + providers: [ + { + provide: UserSession, + scope: 'http', + useFactory: () => { + throw new Error('UserSession must be initialized via injector context'); + } + } + ], + imports: [new FrameworkModule] +}); + +// Initialize user session for each request +app.listen(httpWorkflow.onRequest, (event) => { + const session = new UserSession(); + // Load session data from cookies/storage if needed + event.injectorContext.set(UserSession, session); +}); + +// Authentication middleware using sessions +app.listen(httpWorkflow.onAuth, (event, session: UserSession) => { + // For routes that require authentication, check session + if (event.route.groups.includes('authenticated')) { + if (!session.isAuthenticated()) { + throw new HttpUnauthorizedError('Authentication required'); + } + + // Make user available to controllers via injector context + const user = session.getUser(); + event.injectorContext.set(User, user); + } +}); +``` + +## CORS (Cross-Origin Resource Sharing) + +CORS is essential for web applications that need to handle requests from different domains: + +```typescript +import { HttpRequest, HttpResponse, httpWorkflow } from '@deepkit/http'; + +class CorsMiddleware { + private allowedOrigins = ['http://localhost:3000', 'https://myapp.com']; + private allowedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']; + private allowedHeaders = ['Content-Type', 'Authorization']; + + handleCors(request: HttpRequest, response: HttpResponse): boolean { + const origin = request.headers.origin; + + // Check if origin is allowed + if (origin && this.allowedOrigins.includes(origin)) { + response.setHeader('Access-Control-Allow-Origin', origin); + } + + response.setHeader('Access-Control-Allow-Methods', this.allowedMethods.join(', ')); + response.setHeader('Access-Control-Allow-Headers', this.allowedHeaders.join(', ')); + response.setHeader('Access-Control-Allow-Credentials', 'true'); + + // Handle preflight requests + if (request.method === 'OPTIONS') { + response.statusCode = 200; + response.end(); + return true; // Request handled + } + + return false; // Continue processing + } +} + +// CORS handling in workflow +app.listen(httpWorkflow.onRequest, (event, corsMiddleware: CorsMiddleware) => { + const handled = corsMiddleware.handleCors(event.request, event.response); + if (handled) { + event.stop(); // Stop further processing for OPTIONS requests + } +}); + +// Alternative: Handle CORS for route not found (useful for OPTIONS requests) +app.listen(httpWorkflow.onRouteNotFound, (event) => { + if (event.request.method === 'OPTIONS') { + // Handle CORS preflight + event.response.setHeader('Access-Control-Allow-Origin', '*'); + event.response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + event.response.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + event.send(new JSONResponse(true, 200)); + } +}); +``` + +## Input Validation & Sanitization + +Proper input validation is crucial for security. Deepkit HTTP automatically validates input based on TypeScript types: + +```typescript +import { MinLength, MaxLength, Email, Positive } from '@deepkit/type'; + +interface CreateUserRequest { + username: string & MinLength<3> & MaxLength<20>; + email: string & Email; + age: number & Positive; + password: string & MinLength<8>; +} + +class UserController { + @http.POST('/users') + createUser(userData: HttpBody) { + // userData is automatically validated and type-safe + return { + message: 'User created', + username: userData.username, + email: userData.email + }; + } +} +``` + +## Error Handling + +Proper error handling prevents information leakage and provides better user experience: + +```typescript +import { + HttpBadRequestError, + HttpUnauthorizedError, + HttpAccessDeniedError, + HttpNotFoundError +} from '@deepkit/http'; + +class SecureController { + @http.GET('/sensitive-data/:id') + getSensitiveData(id: number, user: User) { + // Check authorization + if (!user.hasPermission('data', 'read')) { + throw new HttpAccessDeniedError('Insufficient permissions'); + } + + // Validate input + if (id <= 0) { + throw new HttpBadRequestError('Invalid ID provided'); + } + + // Check resource ownership + if (!this.userOwnsResource(user.id, id)) { + throw new HttpNotFoundError('Resource not found'); // Don't reveal existence + } + + return { data: 'sensitive information' }; + } + + private userOwnsResource(userId: number, resourceId: number): boolean { + // Implement ownership check + return true; + } +} + +// Global error handler +app.listen(httpWorkflow.onControllerError, (event) => { + const error = event.error; + + // Log security-related errors + if (error instanceof HttpUnauthorizedError || error instanceof HttpAccessDeniedError) { + console.log(`Security violation: ${error.message} from ${event.request.ip}`); + } + + // Don't expose internal errors in production + if (process.env.NODE_ENV === 'production' && !(error instanceof HttpBadRequestError)) { + event.send(new JSONResponse({ message: 'Internal server error' }, 500)); + } +}); +``` + +## Security Headers + +Implement security headers to protect against common attacks: + +```typescript +class SecurityHeadersMiddleware { + addSecurityHeaders(response: HttpResponse): void { + // Prevent clickjacking + response.setHeader('X-Frame-Options', 'DENY'); + + // Prevent MIME type sniffing + response.setHeader('X-Content-Type-Options', 'nosniff'); + + // Enable XSS protection + response.setHeader('X-XSS-Protection', '1; mode=block'); + + // Enforce HTTPS + response.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + + // Content Security Policy + response.setHeader('Content-Security-Policy', "default-src 'self'"); + + // Referrer Policy + response.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + } +} + +// Apply security headers to all responses +app.listen(httpWorkflow.onResponse, (event, securityHeaders: SecurityHeadersMiddleware) => { + securityHeaders.addSecurityHeaders(event.response); +}); +``` + +## Rate Limiting + +Implement rate limiting to prevent abuse: + +```typescript +class RateLimiter { + private requests = new Map(); + private maxRequests = 100; + private windowMs = 15 * 60 * 1000; // 15 minutes + + isAllowed(ip: string): boolean { + const now = Date.now(); + const userRequests = this.requests.get(ip); + + if (!userRequests || now > userRequests.resetTime) { + this.requests.set(ip, { count: 1, resetTime: now + this.windowMs }); + return true; + } + + if (userRequests.count >= this.maxRequests) { + return false; + } + + userRequests.count++; + return true; + } +} + +// Rate limiting middleware +app.listen(httpWorkflow.onRequest, (event, rateLimiter: RateLimiter) => { + const clientIp = event.request.ip || event.request.headers['x-forwarded-for'] as string; + + if (!rateLimiter.isAllowed(clientIp)) { + event.send(new JSONResponse({ message: 'Too many requests' }, 429)); + } +}); +``` + +## Best Practices + +1. **Always validate input**: Use TypeScript types and validation decorators +2. **Implement proper authentication**: Use secure tokens (JWT) or sessions +3. **Use HTTPS in production**: Encrypt all communication +4. **Sanitize output**: Prevent XSS attacks by properly encoding output +5. **Log security events**: Monitor authentication failures and suspicious activity +6. **Keep dependencies updated**: Regularly update packages for security fixes +7. **Use security headers**: Implement comprehensive security headers +8. **Rate limiting**: Prevent abuse with proper rate limiting +9. **Error handling**: Don't expose sensitive information in error messages +10. **Principle of least privilege**: Grant minimal necessary permissions diff --git a/website/src/pages/documentation/http/testing.md b/website/src/pages/documentation/http/testing.md new file mode 100644 index 000000000..19cf39012 --- /dev/null +++ b/website/src/pages/documentation/http/testing.md @@ -0,0 +1,352 @@ +# Testing + +Testing HTTP applications is crucial for ensuring reliability and correctness. Deepkit HTTP provides comprehensive testing utilities that make it easy to test controllers, middleware, and entire HTTP workflows without starting an actual server. + +## Testing Setup + +Deepkit provides testing utilities through the `@deepkit/framework` package: + +```typescript +import { expect, test } from '@jest/globals'; +import { createTestingApp } from '@deepkit/framework'; +import { HttpRequest } from '@deepkit/http'; + +test('basic http test', async () => { + const testing = createTestingApp({ + controllers: [MyController], + providers: [MyService] + }); + + await testing.startServer(); + + try { + const response = await testing.request(HttpRequest.GET('/hello/World')); + expect(response.statusCode).toBe(200); + expect(response.json).toBe("Hello World!"); + } finally { + await testing.stopServer(); + } +}); +``` + +## Testing Controllers + +### Basic Controller Testing + +```typescript +import { http } from '@deepkit/http'; + +class UserController { + @http.GET('/users/:id') + getUser(id: number) { + return { id, name: `User ${id}` }; + } + + @http.POST('/users') + createUser(userData: { name: string, email: string }) { + return { id: 123, ...userData }; + } +} + +test('user controller', async () => { + const testing = createTestingApp({ + controllers: [UserController] + }); + + // Test GET request + const getResponse = await testing.request(HttpRequest.GET('/users/42')); + expect(getResponse.statusCode).toBe(200); + expect(getResponse.json).toEqual({ id: 42, name: 'User 42' }); + + // Test POST request + const postResponse = await testing.request( + HttpRequest.POST('/users').json({ name: 'John', email: 'john@example.com' }) + ); + expect(postResponse.statusCode).toBe(200); + expect(postResponse.json).toEqual({ + id: 123, + name: 'John', + email: 'john@example.com' + }); +}); +``` + +### Testing with Dependencies + +```typescript +class UserService { + getUser(id: number) { + return { id, name: `User ${id}`, email: `user${id}@example.com` }; + } +} + +class UserController { + constructor(private userService: UserService) {} + + @http.GET('/users/:id') + getUser(id: number) { + return this.userService.getUser(id); + } +} + +test('controller with dependencies', async () => { + const testing = createTestingApp({ + controllers: [UserController], + providers: [UserService] + }); + + const response = await testing.request(HttpRequest.GET('/users/1')); + expect(response.json).toEqual({ + id: 1, + name: 'User 1', + email: 'user1@example.com' + }); +}); +``` + +## Testing Authentication & Authorization + +```typescript +class AuthController { + @http.POST('/login') + login(credentials: { username: string, password: string }) { + if (credentials.username === 'admin' && credentials.password === 'secret') { + return { token: 'valid-token', user: { username: 'admin' } }; + } + throw new HttpUnauthorizedError('Invalid credentials'); + } + + @http.GET('/profile') + getProfile(user: User) { + return user; + } +} + +test('authentication', async () => { + const testing = createTestingApp({ + controllers: [AuthController], + providers: [ + { + provide: User, + scope: 'http', + useFactory: () => { + throw new Error('User must be set via injector context during authentication'); + } + } + ] + }); + + // Add authentication listener for testing + testing.app.listen(httpWorkflow.onAuth, (event) => { + const auth = event.request.headers.authorization; + if (auth === 'Bearer valid-token') { + const user = new User('admin', 1); + event.injectorContext.set(User, user); + } else { + throw new HttpUnauthorizedError('Invalid token'); + } + }); + + // Test login + const loginResponse = await testing.request( + HttpRequest.POST('/login').json({ username: 'admin', password: 'secret' }) + ); + expect(loginResponse.statusCode).toBe(200); + expect(loginResponse.json.token).toBe('valid-token'); + + // Test protected route + const profileResponse = await testing.request( + HttpRequest.GET('/profile').header('authorization', 'Bearer valid-token') + ); + expect(profileResponse.statusCode).toBe(200); + expect(profileResponse.json.username).toBe('admin'); + + // Test unauthorized access + const unauthorizedResponse = await testing.request(HttpRequest.GET('/profile')); + expect(unauthorizedResponse.statusCode).toBe(401); +}); +``` + +## Testing Middleware + +```typescript +import { HttpMiddleware, httpMiddleware } from '@deepkit/http'; + +class LoggingMiddleware implements HttpMiddleware { + constructor(private logs: string[] = []) {} + + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + this.logs.push(`${request.method} ${request.url}`); + next(); + } +} + +test('middleware', async () => { + const logs: string[] = []; + const loggingMiddleware = new LoggingMiddleware(logs); + + const testing = createTestingApp({ + controllers: [UserController], + providers: [loggingMiddleware], + middlewares: [httpMiddleware.for(LoggingMiddleware)] + }); + + await testing.request(HttpRequest.GET('/users/1')); + expect(logs).toContain('GET /users/1'); +}); +``` + +## Testing File Uploads + +```typescript +import { UploadedFile } from '@deepkit/http'; + +class FileController { + @http.POST('/upload') + uploadFile(file: UploadedFile) { + return { + name: file.name, + size: file.size, + type: file.type + }; + } +} + +test('file upload', async () => { + const testing = createTestingApp({ + controllers: [FileController] + }); + + const response = await testing.request( + HttpRequest.POST('/upload').multiPart([ + { + name: 'file', + file: Buffer.from('test file content'), + fileName: 'test.txt' + } + ]) + ); + + expect(response.statusCode).toBe(200); + expect(response.json).toMatchObject({ + name: 'test.txt', + size: 17, + type: 'application/octet-stream' + }); +}); +``` + +## Testing Error Handling + +```typescript +test('error handling', async () => { + class ErrorController { + @http.GET('/error') + throwError() { + throw new HttpBadRequestError('Something went wrong'); + } + } + + const testing = createTestingApp({ + controllers: [ErrorController] + }); + + const response = await testing.request(HttpRequest.GET('/error')); + expect(response.statusCode).toBe(400); + expect(response.json.message).toBe('Something went wrong'); +}); +``` + +## Testing Functional Routes + +```typescript +test('functional routes', async () => { + const testing = createTestingApp({}); + + const router = testing.app.get(HttpRouterRegistry); + + router.get('/hello/:name', (name: string) => { + return `Hello ${name}`; + }); + + router.post('/echo', (body: HttpBody<{ message: string }>) => { + return { echo: body.message }; + }); + + const getResponse = await testing.request(HttpRequest.GET('/hello/World')); + expect(getResponse.json).toBe('Hello World'); + + const postResponse = await testing.request( + HttpRequest.POST('/echo').json({ message: 'test' }) + ); + expect(postResponse.json).toEqual({ echo: 'test' }); +}); +``` + +## Mock Services + +```typescript +class DatabaseService { + async getUser(id: number) { + // Real implementation would query database + throw new Error('Database not available in tests'); + } +} + +class MockDatabaseService { + async getUser(id: number) { + return { id, name: `Mock User ${id}` }; + } +} + +test('with mocked services', async () => { + const testing = createTestingApp({ + controllers: [UserController], + providers: [ + { provide: DatabaseService, useClass: MockDatabaseService } + ] + }); + + const response = await testing.request(HttpRequest.GET('/users/1')); + expect(response.json.name).toBe('Mock User 1'); +}); +``` + +## Integration Testing + +```typescript +test('full integration test', async () => { + const testing = createTestingApp({ + controllers: [UserController, AuthController], + providers: [UserService, AuthService], + middlewares: [httpMiddleware.for(LoggingMiddleware)] + }); + + // Test complete user flow + const loginResponse = await testing.request( + HttpRequest.POST('/login').json({ username: 'admin', password: 'secret' }) + ); + + const token = loginResponse.json.token; + + const userResponse = await testing.request( + HttpRequest.GET('/users/me').header('authorization', `Bearer ${token}`) + ); + + expect(userResponse.statusCode).toBe(200); + expect(userResponse.json.username).toBe('admin'); +}); +``` + +## Best Practices + +1. **Use testing utilities**: Always use `createTestingApp` for HTTP testing +2. **Test both success and error cases**: Ensure proper error handling +3. **Mock external dependencies**: Use mock services for databases, APIs, etc. +4. **Test authentication flows**: Verify both authorized and unauthorized access +5. **Test input validation**: Ensure proper validation of request data +6. **Test middleware**: Verify middleware behavior in isolation +7. **Use proper assertions**: Check status codes, response bodies, and headers +8. **Clean up resources**: Always stop the testing server in finally blocks +9. **Test edge cases**: Include boundary conditions and invalid inputs +10. **Integration tests**: Test complete user workflows end-to-end diff --git a/website/src/pages/documentation/http/views.md b/website/src/pages/documentation/http/views.md index 13be56b3e..bba04e96e 100644 --- a/website/src/pages/documentation/http/views.md +++ b/website/src/pages/documentation/http/views.md @@ -28,4 +28,287 @@ router.get('/', () => ); app.run(); ``` +## Dynamic Views + +JSX views can be dynamic and accept props: + +```tsx +interface UserProfileProps { + user: { + id: number; + name: string; + email: string; + }; +} + +function UserProfile({ user }: UserProfileProps) { + return
+

User Profile

+
+ ID: {user.id} +
+
+ Name: {user.name} +
+
+ Email: {user.email} +
+
; +} + +class UserController { + @http.GET('/users/:id') + getUser(id: number) { + const user = { id, name: `User ${id}`, email: `user${id}@example.com` }; + return ; + } +} +``` + +## Layout Components + +Create reusable layout components: + +```tsx +interface LayoutProps { + title: string; + children: any; +} + +function Layout({ title, children }: LayoutProps) { + return + + {title} + + + + +
+ +
+
+ {children} +
+
+

© 2024 My Website

+
+ + ; +} + +function HomePage() { + return +

Welcome to My Website

+

This is the home page content.

+
; +} + +router.get('/', () => ); +``` + +## Conditional Rendering + +Use JavaScript logic for conditional rendering: + +```tsx +interface ProductListProps { + products: Array<{ id: number; name: string; price: number; inStock: boolean }>; + user?: { isAdmin: boolean }; +} + +function ProductList({ products, user }: ProductListProps) { + return
+

Products

+ {products.length === 0 ? ( +

No products available.

+ ) : ( +
+ {products.map(product => ( +
+

{product.name}

+

Price: ${product.price}

+ {!product.inStock &&

Out of Stock

} + {user?.isAdmin && ( + + )} +
+ ))} +
+ )} +
; +} +``` + +## Forms and Input Handling + +Create forms with proper structure: + +```tsx +interface ContactFormProps { + errors?: { [key: string]: string }; + values?: { [key: string]: string }; +} + +function ContactForm({ errors = {}, values = {} }: ContactFormProps) { + return +

Contact Us

+
+
+ + + {errors.name && {errors.name}} +
+ +
+ + + {errors.email && {errors.email}} +
+ +
+ + + {errors.message && {errors.message}} +
+ + +
+
; +} + +class ContactController { + @http.GET('/contact') + showForm() { + return ; + } + + @http.POST('/contact') + submitForm(formData: { name: string; email: string; message: string }) { + const errors: { [key: string]: string } = {}; + + if (!formData.name) errors.name = 'Name is required'; + if (!formData.email) errors.email = 'Email is required'; + if (!formData.message) errors.message = 'Message is required'; + + if (Object.keys(errors).length > 0) { + return ; + } + + // Process form submission + return +

Thank You!

+

Your message has been sent successfully.

+
; + } +} +``` + +## Styling + +Add CSS styles to your views: + +```tsx +function StyledPage() { + return + + Styled Page + + + +
+

Styled Content

+

This page has custom styling.

+ +
+ + ; +} +``` + +## Response Types + +Return different response types from views: + +```tsx +import { HtmlResponse, JSONResponse } from '@deepkit/http'; + +class ApiController { + @http.GET('/api/data') + getData(format?: string) { + const data = { message: 'Hello World', timestamp: new Date() }; + + if (format === 'json') { + return new JSONResponse(data); + } + + // Return HTML view + return new HtmlResponse(
+

{data.message}

+

Generated at: {data.timestamp.toISOString()}

+
); + } +} +``` + +## Best Practices + +1. **Component composition**: Break views into reusable components +2. **Type safety**: Use TypeScript interfaces for props +3. **Separation of concerns**: Keep logic separate from presentation +4. **Accessibility**: Use semantic HTML and proper attributes +5. **Performance**: Avoid complex computations in render functions +6. **Error handling**: Provide fallbacks for missing data +7. **SEO friendly**: Use proper meta tags and semantic markup +8. **Responsive design**: Consider mobile-first approaches + ```sh From 28152881696cf689d9e107c153227f983de112ae Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 19:45:38 +0200 Subject: [PATCH 05/15] docs: improve framework --- website/src/pages/documentation/framework.md | 136 ++++- .../framework/application-server.md | 340 ++++++++++++ .../pages/documentation/framework/broker.md | 419 +++++++++++++++ .../documentation/framework/configuration.md | 267 ++++++++++ .../framework/debugging-profiling.md | 370 +++++++++++++ .../pages/documentation/framework/events.md | 426 +++++++++++++++ .../documentation/framework/filesystem.md | 495 ++++++++++++++++++ .../framework/getting-started.md | 208 ++++++++ .../src/pages/documentation/framework/rpc.md | 387 ++++++++++++++ .../pages/documentation/framework/testing.md | 432 ++++++++++++++- .../pages/documentation/framework/workers.md | 430 +++++++++++++++ .../pages/documentation/framework/zones.md | 431 +++++++++++++++ 12 files changed, 4305 insertions(+), 36 deletions(-) create mode 100644 website/src/pages/documentation/framework/application-server.md create mode 100644 website/src/pages/documentation/framework/broker.md create mode 100644 website/src/pages/documentation/framework/configuration.md create mode 100644 website/src/pages/documentation/framework/debugging-profiling.md create mode 100644 website/src/pages/documentation/framework/events.md create mode 100644 website/src/pages/documentation/framework/filesystem.md create mode 100644 website/src/pages/documentation/framework/getting-started.md create mode 100644 website/src/pages/documentation/framework/rpc.md create mode 100644 website/src/pages/documentation/framework/workers.md create mode 100644 website/src/pages/documentation/framework/zones.md diff --git a/website/src/pages/documentation/framework.md b/website/src/pages/documentation/framework.md index 014934d9a..3e084b923 100644 --- a/website/src/pages/documentation/framework.md +++ b/website/src/pages/documentation/framework.md @@ -3,6 +3,26 @@ Deepkit Framework is a highly modular, scalable, and fast TypeScript framework for building web applications, APIs, and microservices. It is designed to be as flexible as necessary and as structured as required, allowing developers to maintain high development speeds, both in the short term and the long term. +## Overview + +Deepkit Framework provides a comprehensive set of features for modern web development: + +### Core Features +- **Application Server** - HTTP and RPC servers with multi-process support +- **Dependency Injection** - Powerful service container with scoped providers +- **Real-time Communication** - WebSocket-based RPC with type safety +- **Database Integration** - Built-in ORM with migration support +- **Message Broker** - Inter-process communication and caching +- **Testing Utilities** - Comprehensive testing framework +- **Debug & Profiling** - Built-in debugging and performance tools + +### Architecture +- **Modular Design** - Import only what you need +- **Type Safety** - Full TypeScript support throughout +- **Event-Driven** - Comprehensive event system +- **Multi-Process** - Worker process support for scalability +- **Cloud-Ready** - Built for modern deployment scenarios + ## App and Framework Module Deepkit Framework is based on [Deepkit App](./app.md) in `@deepkit/app` and provides the `FrameworkModule` module in `@deepkit/framework`, which can be imported in your `App`. @@ -17,17 +37,26 @@ The `App` abstraction brings: The `FrameworkModule` module brings additional features: -- Application server - - HTTP server - - RPC server +- **Application Server** + - HTTP server with middleware support + - RPC server with WebSocket communication - Multi-process load balancing - - SSL -- Debugging CLI commands -- Database Migration configuration/commands -- Debugging/Profiler GUI via `{debug: true}` option -- Interactive API documentation (like Swagger) -- Providers for DatabaseRegistry, ProcessLocking, Broker, Sessions -- Integration Test APIs + - SSL/HTTPS support + - Static file serving +- **Development Tools** + - Interactive debugger and profiler + - Database browser and migration tools + - API documentation interface + - Request/response logging +- **Production Features** + - Message broker for inter-process communication + - Distributed caching and locking + - Session management + - Graceful shutdown handling +- **Testing Support** + - In-memory testing utilities + - Mock services and adapters + - Integration testing framework You can write applications with or without the `FrameworkModule`. @@ -188,33 +217,90 @@ Modules config ... ``` -## Application Server +## Documentation + +### Getting Started +- [Getting Started](./framework/getting-started.md) - Quick start guide and basic concepts +- [Configuration](./framework/configuration.md) - Complete configuration reference +- [Application Server](./framework/application-server.md) - Server lifecycle and management -## File Structure +### Core Features +- [RPC](./framework/rpc.md) - Real-time communication with WebSockets +- [Database](./framework/database.md) - Database integration and migrations +- [Testing](./framework/testing.md) - Comprehensive testing strategies +- [Events](./framework/events.md) - Event system and lifecycle hooks -## Auto-CRUD +### Advanced Topics +- [Workers](./framework/workers.md) - Multi-process architecture +- [Broker](./framework/broker.md) - Message broker and inter-process communication +- [Debugging & Profiling](./framework/debugging-profiling.md) - Debug tools and performance analysis +- [Zones](./framework/zones.md) - Request context management +- [Filesystem](./framework/filesystem.md) - File storage abstraction -## Events +### Deployment & Production +- [Deployment](./framework/deployment.md) - Production deployment strategies +- [Public Directory](./framework/public.md) - Static file serving +- [API Console](./framework/api-console.md) - Interactive API documentation -Deepkit framework comes with various event tokens on which event listeners can be registered. +### Related Documentation +- [HTTP](./http.md) - HTTP controllers and REST APIs +- [Dependency Injection](./dependency-injection.md) - Service container and providers +- [ORM](./orm.md) - Database modeling and queries +- [App](./app.md) - Application foundation and CLI -See the [Events](./app/events.md) chapter to learn more about how events work. +## Quick Examples + +### HTTP API +```typescript +import { http } from '@deepkit/http'; -### Dispatch Events +class UserController { + @http.GET('/users/:id') + getUser(@http.param() id: number) { + return { id, name: `User ${id}` }; + } -Events are sent via the `EventDispatcher` class. In a Deepkit Framework application, this can be provided via dependency injection. + @http.POST('/users') + createUser(@http.body() userData: CreateUserData) { + return this.userService.create(userData); + } +} +``` +### RPC Controller ```typescript -import { cli, Command } from '@deepkit/app'; -import { EventDispatcher } from '@deepkit/event'; +import { rpc } from '@deepkit/rpc'; -@cli.controller('test') -export class TestCommand implements Command { - constructor(protected eventDispatcher: EventDispatcher) { +@rpc.controller('users') +class UserRpcController { + @rpc.action() + async getUser(id: number): Promise { + return await this.userService.findById(id); } - async execute() { - this.eventDispatcher.dispatch(UserAdded, new UserEvent({ username: 'Peter' })); + @rpc.action() + getUserUpdates(): Observable { + return this.userService.getUpdateStream(); + } +} +``` + +### Service with Dependencies +```typescript +class UserService { + constructor( + private database: Database, + private logger: Logger, + private eventDispatcher: EventDispatcher + ) {} + + async createUser(userData: CreateUserData): Promise { + const user = await this.database.persist(new User(userData)); + + await this.eventDispatcher.dispatch(onUserCreated, new UserCreatedEvent(user.id)); + this.logger.log(`User created: ${user.id}`); + + return user; } } ``` diff --git a/website/src/pages/documentation/framework/application-server.md b/website/src/pages/documentation/framework/application-server.md new file mode 100644 index 000000000..096209c51 --- /dev/null +++ b/website/src/pages/documentation/framework/application-server.md @@ -0,0 +1,340 @@ +# Application Server + +The `ApplicationServer` is the core component that manages HTTP and RPC servers, worker processes, and the application lifecycle. This chapter covers how to work with the application server and its lifecycle events. + +## Basic Usage + +The application server is automatically created when you import the `FrameworkModule`: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; + +const app = new App({ + imports: [new FrameworkModule()] +}); + +// Start the server +app.run(['server:start']); +``` + +## Accessing the Application Server + +You can inject the `ApplicationServer` into your services: + +```typescript +import { ApplicationServer } from '@deepkit/framework'; + +class MyService { + constructor(private server: ApplicationServer) {} + + getServerInfo() { + return { + host: this.server.getHttpHost(), + started: this.server.started + }; + } +} +``` + +## Server Lifecycle Events + +The application server emits various lifecycle events that you can listen to: + +### Bootstrap Events + +```typescript +import { + onServerBootstrap, + onServerBootstrapDone, + onServerMainBootstrap, + onServerMainBootstrapDone, + onServerWorkerBootstrap, + onServerWorkerBootstrapDone +} from '@deepkit/framework'; + +class MyListener { + @eventDispatcher.listen(onServerBootstrap) + onBootstrap() { + console.log('Server is bootstrapping...'); + } + + @eventDispatcher.listen(onServerBootstrapDone) + onBootstrapDone() { + console.log('Server bootstrap completed'); + } + + @eventDispatcher.listen(onServerMainBootstrap) + onMainBootstrap() { + console.log('Main process bootstrapping...'); + } + + @eventDispatcher.listen(onServerWorkerBootstrap) + onWorkerBootstrap() { + console.log('Worker process bootstrapping...'); + } +} +``` + +### Shutdown Events + +```typescript +import { onServerShutdown, onServerMainShutdown } from '@deepkit/framework'; + +class ShutdownListener { + @eventDispatcher.listen(onServerShutdown) + onShutdown() { + console.log('Server is shutting down...'); + // Cleanup resources + } + + @eventDispatcher.listen(onServerMainShutdown) + onMainShutdown() { + console.log('Main process shutting down...'); + // Main process cleanup + } +} +``` + +## Manual Server Control + +For testing or custom scenarios, you can manually control the server: + +```typescript +import { ApplicationServer } from '@deepkit/framework'; + +class ServerManager { + constructor(private server: ApplicationServer) {} + + async startServer() { + await this.server.start(); + console.log('Server started manually'); + } + + async stopServer() { + await this.server.close(true); // graceful shutdown + console.log('Server stopped'); + } +} +``` + +## Worker Processes + +The application server supports multi-process architecture: + +```typescript +new FrameworkModule({ + workers: 4 // Use 4 worker processes +}) +``` + +### Worker Lifecycle + +- **Master Process**: Manages workers, handles process coordination +- **Worker Processes**: Handle HTTP/RPC requests + +```typescript +import cluster from 'cluster'; + +class WorkerAwareService { + getProcessInfo() { + return { + isMaster: cluster.isMaster, + isWorker: cluster.isWorker, + workerId: cluster.worker?.id, + pid: process.pid + }; + } +} +``` + +## HTTP Worker + +Access the HTTP worker for advanced scenarios: + +```typescript +import { ApplicationServer } from '@deepkit/framework'; + +class HttpService { + constructor(private server: ApplicationServer) {} + + getHttpWorker() { + return this.server.getHttpWorker(); + } + + async handleCustomRequest(request: any, response: any) { + const worker = this.server.getHttpWorker(); + await worker.handleRequest(request, response); + } +} +``` + +## RPC Client Creation + +Create RPC clients for inter-service communication: + +```typescript +import { ApplicationServer } from '@deepkit/framework'; + +class RpcService { + constructor(private server: ApplicationServer) {} + + createClient() { + return this.server.createClient(); + } + + async callRemoteService() { + const client = this.createClient(); + const controller = client.controller('remote-service'); + return await controller.someMethod(); + } +} +``` + +## Server Configuration + +Configure server behavior through the framework module: + +```typescript +new FrameworkModule({ + // Basic server settings + host: '0.0.0.0', + port: 8080, + + // Worker configuration + workers: 0, // Single process + + // Shutdown behavior + gracefulShutdownTimeout: 10, + + // SSL configuration + ssl: false, + selfSigned: false, + + // Logging + logStartup: true +}) +``` + +## Graceful Shutdown + +The server supports graceful shutdown with configurable timeout: + +```typescript +// Configure graceful shutdown timeout +new FrameworkModule({ + gracefulShutdownTimeout: 30 // 30 seconds +}) + +// Manual graceful shutdown +class ShutdownService { + constructor(private server: ApplicationServer) {} + + async gracefulShutdown() { + await this.server.close(true); // graceful = true + } +} +``` + +## Server Information + +Get runtime information about the server: + +```typescript +class ServerInfoService { + constructor(private server: ApplicationServer) {} + + getInfo() { + return { + httpHost: this.server.getHttpHost(), + hasHttpWorker: !!this.server.getHttpWorker(), + // Add custom server information + }; + } +} +``` + +## Custom Server Instance + +Use a custom HTTP/HTTPS server: + +```typescript +import { createServer } from 'http'; +import { createServer as createHttpsServer } from 'https'; + +// Custom HTTP server +const httpServer = createServer(); + +new FrameworkModule({ + server: httpServer +}) + +// Custom HTTPS server +const httpsServer = createHttpsServer({ + key: fs.readFileSync('private-key.pem'), + cert: fs.readFileSync('certificate.pem') +}); + +new FrameworkModule({ + server: httpsServer, + ssl: true +}) +``` + +## Error Handling + +Handle server errors and failures: + +```typescript +class ErrorHandler { + @eventDispatcher.listen(onServerBootstrap) + async onBootstrap() { + try { + // Server initialization logic + } catch (error) { + console.error('Server bootstrap failed:', error); + process.exit(1); + } + } +} +``` + +## Testing with Application Server + +For integration testing, use the testing utilities: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +test('server lifecycle', async () => { + const testing = createTestingApp({ + controllers: [MyController] + }); + + // Start server + await testing.startServer(); + + // Test server functionality + const response = await testing.request(HttpRequest.GET('/')); + expect(response.statusCode).toBe(200); + + // Stop server + await testing.stopServer(); +}); +``` + +## Best Practices + +1. **Use lifecycle events** for initialization and cleanup +2. **Configure workers** based on your server capacity +3. **Implement graceful shutdown** for production deployments +4. **Monitor server health** through custom services +5. **Handle errors properly** during bootstrap and runtime +6. **Use testing utilities** for integration tests + +## Next Steps + +- [Workers](./workers.md) - Multi-process configuration +- [Events](./events.md) - Event system and lifecycle hooks +- [Testing](./testing.md) - Testing server functionality +- [Deployment](./deployment.md) - Production deployment strategies diff --git a/website/src/pages/documentation/framework/broker.md b/website/src/pages/documentation/framework/broker.md new file mode 100644 index 000000000..62d363796 --- /dev/null +++ b/website/src/pages/documentation/framework/broker.md @@ -0,0 +1,419 @@ +# Message Broker + +Deepkit Framework includes a built-in message broker that provides inter-process communication, caching, queuing, and pub/sub messaging. The broker enables distributed architectures and real-time features. + +## Overview + +The broker provides several key services: + +- **Message Bus**: Pub/sub messaging between processes +- **Cache**: Distributed caching with TTL support +- **Queue**: Task queuing and processing +- **Key-Value Store**: Distributed key-value storage +- **Locking**: Distributed locking mechanisms + +## Basic Configuration + +The broker is automatically configured when you use the `FrameworkModule`: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; + +const app = new App({ + imports: [ + new FrameworkModule({ + broker: { + startOnBootstrap: true, // Start broker automatically + listen: 'localhost:8811', // Broker listen address + host: 'localhost:8811' // Broker host to connect to + } + }) + ] +}); +``` + +## Message Bus (Pub/Sub) + +Use the message bus for pub/sub communication: + +```typescript +import { BrokerBus } from '@deepkit/broker'; + +class NotificationService { + constructor(private bus: BrokerBus) {} + + async publishNotification(userId: number, message: string) { + await this.bus.publish('user.notification', { + userId, + message, + timestamp: new Date() + }); + } + + async subscribeToNotifications() { + const subscription = await this.bus.subscribe('user.notification'); + + subscription.subscribe((message) => { + console.log('Received notification:', message); + }); + + return subscription; + } +} +``` + +### Channel Patterns + +Use patterns for flexible subscriptions: + +```typescript +class EventHandler { + constructor(private bus: BrokerBus) {} + + async setupSubscriptions() { + // Subscribe to all user events + const userEvents = await this.bus.subscribe('user.*'); + + // Subscribe to all error events + const errorEvents = await this.bus.subscribe('*.error'); + + userEvents.subscribe(message => { + console.log('User event:', message); + }); + + errorEvents.subscribe(message => { + console.error('Error event:', message); + }); + } +} +``` + +## Distributed Cache + +Use the broker cache for distributed caching: + +```typescript +import { BrokerCache } from '@deepkit/broker'; + +class CacheService { + constructor(private cache: BrokerCache) {} + + async cacheUser(userId: number, userData: any) { + // Cache for 1 hour (3600 seconds) + await this.cache.set(`user:${userId}`, userData, 3600); + } + + async getUser(userId: number) { + return await this.cache.get(`user:${userId}`); + } + + async invalidateUser(userId: number) { + await this.cache.delete(`user:${userId}`); + } + + async getUserWithFallback(userId: number) { + let user = await this.cache.get(`user:${userId}`); + + if (!user) { + user = await this.loadUserFromDatabase(userId); + await this.cache.set(`user:${userId}`, user, 3600); + } + + return user; + } +} +``` + +### Cache Patterns + +```typescript +class ProductService { + constructor(private cache: BrokerCache) {} + + async getPopularProducts() { + const cacheKey = 'products:popular'; + let products = await this.cache.get(cacheKey); + + if (!products) { + products = await this.calculatePopularProducts(); + // Cache for 30 minutes + await this.cache.set(cacheKey, products, 1800); + } + + return products; + } + + async invalidateProductCache() { + // Clear all product-related cache entries + await this.cache.deletePattern('products:*'); + } +} +``` + +## Task Queue + +Use the broker queue for background task processing: + +```typescript +import { BrokerQueue } from '@deepkit/broker'; + +class EmailService { + constructor(private queue: BrokerQueue) {} + + async sendEmailAsync(to: string, subject: string, body: string) { + // Add email to queue for background processing + await this.queue.add('email.send', { + to, + subject, + body, + timestamp: new Date() + }); + } + + async processEmailQueue() { + const subscription = await this.queue.subscribe('email.send'); + + subscription.subscribe(async (job) => { + try { + await this.sendEmailNow(job.data); + await job.ack(); // Acknowledge successful processing + } catch (error) { + console.error('Email sending failed:', error); + await job.nack(); // Negative acknowledgment + } + }); + } + + private async sendEmailNow(emailData: any) { + // Actual email sending logic + console.log(`Sending email to ${emailData.to}`); + } +} +``` + +### Queue with Retry Logic + +```typescript +class TaskProcessor { + constructor(private queue: BrokerQueue) {} + + async processWithRetry() { + const subscription = await this.queue.subscribe('heavy.task'); + + subscription.subscribe(async (job) => { + const maxRetries = 3; + let attempt = 0; + + while (attempt < maxRetries) { + try { + await this.processHeavyTask(job.data); + await job.ack(); + return; + } catch (error) { + attempt++; + console.error(`Attempt ${attempt} failed:`, error); + + if (attempt >= maxRetries) { + await job.nack(); + // Move to dead letter queue or log error + } else { + // Wait before retry + await new Promise(resolve => + setTimeout(resolve, 1000 * attempt) + ); + } + } + } + }); + } +} +``` + +## Key-Value Store + +Use the broker for distributed key-value storage: + +```typescript +import { BrokerKeyValue } from '@deepkit/broker'; + +class ConfigService { + constructor(private kv: BrokerKeyValue) {} + + async setConfig(key: string, value: any) { + await this.kv.set(`config:${key}`, value); + } + + async getConfig(key: string, defaultValue?: any) { + const value = await this.kv.get(`config:${key}`); + return value !== undefined ? value : defaultValue; + } + + async getAllConfigs() { + return await this.kv.getPattern('config:*'); + } +} +``` + +## Distributed Locking + +Use broker locks for distributed synchronization: + +```typescript +import { BrokerLock } from '@deepkit/broker'; + +class CriticalSectionService { + constructor(private lock: BrokerLock) {} + + async processExclusively(resourceId: string) { + const lockKey = `process:${resourceId}`; + const acquired = await this.lock.acquire(lockKey, 30); // 30 second timeout + + if (!acquired) { + throw new Error('Could not acquire lock'); + } + + try { + // Critical section - only one process can execute this + await this.performCriticalOperation(resourceId); + } finally { + await this.lock.release(lockKey); + } + } + + async tryProcessWithTimeout(resourceId: string) { + const lockKey = `process:${resourceId}`; + + try { + await this.lock.acquire(lockKey, 10); // 10 second timeout + await this.performCriticalOperation(resourceId); + } catch (error) { + if (error.message.includes('timeout')) { + console.log('Could not acquire lock within timeout'); + } else { + throw error; + } + } finally { + await this.lock.release(lockKey); + } + } +} +``` + +## Multi-Process Communication + +Use the broker for communication between worker processes: + +```typescript +class WorkerCoordinator { + constructor(private bus: BrokerBus) {} + + async coordinateWork() { + // Master process publishes work + if (cluster.isMaster) { + await this.bus.publish('work.available', { + taskId: 'task-123', + data: { /* task data */ } + }); + } + + // Worker processes subscribe to work + if (cluster.isWorker) { + const subscription = await this.bus.subscribe('work.available'); + subscription.subscribe(async (message) => { + await this.processTask(message.data); + + // Report completion + await this.bus.publish('work.completed', { + taskId: message.taskId, + workerId: cluster.worker.id + }); + }); + } + } +} +``` + +## Custom Broker Adapter + +Create custom broker adapters for different backends: + +```typescript +import { BrokerAdapter } from '@deepkit/broker'; + +class RedisBrokerAdapter implements BrokerAdapter { + // Implement broker interface for Redis + async publish(channel: string, message: any) { + // Redis pub/sub implementation + } + + async subscribe(channel: string) { + // Redis subscription implementation + } + + // Implement other broker methods... +} + +// Use custom adapter +const app = new App({ + providers: [ + { provide: BrokerAdapter, useClass: RedisBrokerAdapter } + ], + imports: [new FrameworkModule()] +}); +``` + +## Testing with Broker + +Test broker functionality using the testing utilities: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +test('broker messaging', async () => { + const testing = createTestingApp({ + providers: [NotificationService] + }); + + const service = testing.app.get(NotificationService); + const bus = testing.app.get(BrokerBus); + + // Set up subscription + const messages: any[] = []; + const subscription = await bus.subscribe('test.message'); + subscription.subscribe(msg => messages.push(msg)); + + // Publish message + await bus.publish('test.message', { content: 'Hello' }); + + // Wait for message processing + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('Hello'); +}); +``` + +## Performance Considerations + +1. **Connection Pooling**: Reuse broker connections +2. **Message Size**: Keep messages reasonably sized +3. **TTL Settings**: Set appropriate cache TTL values +4. **Queue Management**: Monitor queue sizes and processing rates +5. **Lock Timeouts**: Use reasonable lock timeouts + +## Best Practices + +1. **Error Handling**: Implement proper error handling for broker operations +2. **Monitoring**: Monitor broker performance and queue sizes +3. **Cleanup**: Clean up subscriptions and resources +4. **Testing**: Test broker functionality thoroughly +5. **Documentation**: Document message formats and channels +6. **Security**: Secure broker communication in production + +## Next Steps + +- [Workers](./workers.md) - Multi-process architecture +- [Events](./events.md) - Event-driven architecture +- [Testing](./testing.md) - Testing distributed systems +- [Performance](../performance.md) - Performance optimization diff --git a/website/src/pages/documentation/framework/configuration.md b/website/src/pages/documentation/framework/configuration.md new file mode 100644 index 000000000..dd1c95ede --- /dev/null +++ b/website/src/pages/documentation/framework/configuration.md @@ -0,0 +1,267 @@ +# Configuration + +The Deepkit Framework provides extensive configuration options through the `FrameworkModule`. This chapter covers all available configuration options and how to use them. + +## Basic Configuration + +Configure the framework module when importing it: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; + +const app = new App({ + imports: [ + new FrameworkModule({ + host: '0.0.0.0', + port: 8080, + debug: true + }) + ] +}); +``` + +## Server Configuration + +### Basic Server Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `host` | string | `'0.0.0.0'` | Host to bind the server to | +| `port` | number | `8080` | Port to listen on | +| `path` | string | `'/'` | Base path for the application | +| `workers` | number | `0` | Number of worker processes (0 = single process) | +| `gracefulShutdownTimeout` | number | `5` | Timeout in seconds for graceful shutdown | + +```typescript +new FrameworkModule({ + host: 'localhost', + port: 3000, + path: '/api', + workers: 4, + gracefulShutdownTimeout: 10 +}) +``` + +### SSL/HTTPS Configuration + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `ssl` | boolean | `false` | Enable HTTPS server | +| `httpsPort` | number? | - | HTTPS port (if different from main port) | +| `selfSigned` | boolean? | - | Generate self-signed certificate for development | +| `sslKey` | string? | - | Path to SSL private key file | +| `sslCertificate` | string? | - | Path to SSL certificate file | +| `sslCa` | string? | - | Path to SSL CA file | +| `sslCrl` | string? | - | Path to SSL CRL file | +| `sslOptions` | object? | - | Additional SSL options | + +```typescript +// Development with self-signed certificate +new FrameworkModule({ + ssl: true, + selfSigned: true +}) + +// Production with real certificates +new FrameworkModule({ + ssl: true, + sslKey: '/path/to/private.key', + sslCertificate: '/path/to/certificate.crt', + sslCa: '/path/to/ca.crt' +}) +``` + +## Static Files + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `publicDir` | string? | - | Directory to serve static files from | +| `publicDirPrefix` | string | `'/'` | URL prefix for static files | + +```typescript +new FrameworkModule({ + publicDir: 'public', + publicDirPrefix: '/static' +}) +``` + +Files in `public/` will be available at `http://localhost:8080/static/`. + +## Debug and Profiling + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `debug` | boolean | `false` | Enable debug mode and web debugger | +| `profile` | boolean | `false` | Enable profiling (auto-enabled with debug) | +| `debugUrl` | string | `'_debug'` | URL path for debug interface | +| `debugBrokerHost` | string? | - | Broker host for debug communication | +| `varPath` | string | `'var/'` | Directory for temporary files | +| `debugStorePath` | string | `'debug/'` | Debug data storage path (relative to varPath) | + +```typescript +new FrameworkModule({ + debug: true, + debugUrl: 'admin/debug', + varPath: 'tmp/', + debugStorePath: 'debug-data/' +}) +``` + +Access debugger at: `http://localhost:8080/admin/debug` + +## Database Configuration + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `migrateOnStartup` | boolean | `false` | Automatically run migrations on startup | +| `migrationDir` | string | `'migrations'` | Directory containing migration files | + +```typescript +new FrameworkModule({ + migrateOnStartup: true, + migrationDir: 'src/migrations' +}) +``` + +## Logging Configuration + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `httpLog` | boolean | `true` | Log HTTP requests | +| `logStartup` | boolean | `true` | Log startup information | + +```typescript +new FrameworkModule({ + httpLog: false, // Disable HTTP request logging + logStartup: true +}) +``` + +## Broker Configuration + +The framework includes a message broker for inter-process communication: + +```typescript +new FrameworkModule({ + broker: { + listen: 'localhost:8811', // Broker listen address + host: 'localhost:8811', // Broker host to connect to + startOnBootstrap: true // Start broker automatically + } +}) +``` + +## HTTP Configuration + +Forward HTTP-specific configuration to the HTTP module: + +```typescript +new FrameworkModule({ + http: { + compression: true, + maxPayload: '10mb', + cors: true + } +}) +``` + +## RPC Configuration + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `httpRpcBasePath` | string | `''` | Enable HTTP-based RPC calls at this path | + +```typescript +new FrameworkModule({ + httpRpcBasePath: '/rpc/v1' // Enable RPC over HTTP +}) +``` + +## Environment-Based Configuration + +Use environment variables to configure the framework: + +```typescript +class AppConfig { + framework = { + host: '0.0.0.0', + port: 8080, + debug: false, + ssl: false + }; +} + +const app = new App({ + config: AppConfig, + imports: [new FrameworkModule()] +}); + +// Load from environment with prefix +app.loadConfigFromEnv({ prefix: 'APP_' }); +``` + +Set environment variables: + +```bash +APP_FRAMEWORK_HOST=localhost +APP_FRAMEWORK_PORT=3000 +APP_FRAMEWORK_DEBUG=true +APP_FRAMEWORK_SSL=true +``` + +## Configuration Validation + +View current configuration: + +```bash +ts-node app.ts app:config +``` + +This shows all configuration values including defaults and environment overrides. + +## Advanced Configuration + +### Custom Server Instance + +Provide your own HTTP/HTTPS server: + +```typescript +import { createServer } from 'http'; + +const server = createServer(); + +new FrameworkModule({ + server: server +}) +``` + +### Session Configuration + +Configure custom session handling: + +```typescript +class CustomSession { + // Custom session implementation +} + +new FrameworkModule({ + session: CustomSession +}) +``` + +## Configuration Best Practices + +1. **Use environment variables** for deployment-specific settings +2. **Enable debug mode** only in development +3. **Configure SSL** properly for production +4. **Set appropriate worker count** based on your server capacity +5. **Use configuration classes** for type safety +6. **Validate configuration** before deployment + +## Next Steps + +- [Application Server](./application-server.md) - Server lifecycle and management +- [SSL/HTTPS](./ssl-https.md) - Detailed SSL configuration +- [Workers](./workers.md) - Multi-process configuration +- [Debugging](./debugging-profiling.md) - Debug and profiling features diff --git a/website/src/pages/documentation/framework/debugging-profiling.md b/website/src/pages/documentation/framework/debugging-profiling.md new file mode 100644 index 000000000..2f86ed6c7 --- /dev/null +++ b/website/src/pages/documentation/framework/debugging-profiling.md @@ -0,0 +1,370 @@ +# Debugging and Profiling + +Deepkit Framework provides powerful debugging and profiling tools that help you understand your application's behavior, performance characteristics, and identify bottlenecks. + +## Debug Mode + +Enable debug mode to access the web-based debugger: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; + +const app = new App({ + imports: [ + new FrameworkModule({ + debug: true, + debugUrl: '_debug' // Custom debug URL (optional) + }) + ] +}); +``` + +Start your server and visit `http://localhost:8080/_debug` to access the debugger. + +## Debug Interface Features + +The debug interface provides several tools: + +### 1. Configuration Viewer +- View all configuration values +- See environment variable overrides +- Inspect module configurations + +### 2. Route Inspector +- View all HTTP routes +- See RPC controllers and actions +- Inspect route parameters and return types + +### 3. Database Browser +- Browse database schemas +- View and edit data +- Execute queries +- See entity relationships + +### 4. Profiler +- View request performance +- Analyze database queries +- See memory usage +- Track execution time + +## Profiling + +Enable profiling to collect performance data: + +```typescript +new FrameworkModule({ + profile: true, // Enable profiling + debug: true // Debug mode auto-enables profiling +}) +``` + +### Stopwatch Integration + +The framework automatically tracks performance using the Stopwatch system: + +```typescript +import { Stopwatch } from '@deepkit/stopwatch'; + +class MyService { + constructor(private stopwatch: Stopwatch) {} + + async performOperation() { + const frame = this.stopwatch.start('MyService.performOperation'); + + try { + // Your operation here + await this.doSomething(); + } finally { + frame.end(); + } + } + + async doSomething() { + // Nested operations are automatically tracked + const frame = this.stopwatch.start('MyService.doSomething'); + + // Simulate work + await new Promise(resolve => setTimeout(resolve, 100)); + + frame.end(); + } +} +``` + +### Automatic HTTP Profiling + +HTTP requests are automatically profiled when debug mode is enabled: + +```typescript +class UserController { + @http.GET('/users/:id') + async getUser(@http.param() id: number) { + // This method's execution time is automatically tracked + return await this.userService.findById(id); + } +} +``` + +### Database Query Profiling + +Database queries are automatically tracked: + +```typescript +class UserService { + constructor(private database: Database) {} + + async findUser(id: number) { + // Query execution time is automatically tracked + return await this.database.query(User).filter({ id }).findOne(); + } +} +``` + +## Custom Profiling + +Add custom profiling to your code: + +```typescript +import { Stopwatch } from '@deepkit/stopwatch'; + +class AnalyticsService { + constructor(private stopwatch: Stopwatch) {} + + async processAnalytics(data: any[]) { + const frame = this.stopwatch.start('Analytics.process', { + category: 'analytics', + data: { recordCount: data.length } + }); + + try { + for (const item of data) { + await this.processItem(item); + } + } finally { + frame.end(); + } + } + + private async processItem(item: any) { + const frame = this.stopwatch.start('Analytics.processItem'); + + // Processing logic + await this.validateItem(item); + await this.saveItem(item); + + frame.end(); + } +} +``` + +## Memory Profiling + +Track memory usage in your application: + +```typescript +class MemoryIntensiveService { + constructor(private stopwatch: Stopwatch) {} + + async processLargeDataset(data: any[]) { + const frame = this.stopwatch.start('LargeDataset.process'); + + const initialMemory = process.memoryUsage(); + + try { + // Process data + const results = await this.processData(data); + + const finalMemory = process.memoryUsage(); + const memoryDelta = finalMemory.heapUsed - initialMemory.heapUsed; + + frame.data({ memoryUsed: memoryDelta }); + + return results; + } finally { + frame.end(); + } + } +} +``` + +## Debug Storage + +Configure where debug data is stored: + +```typescript +new FrameworkModule({ + debug: true, + varPath: 'var/', // Base directory for temporary files + debugStorePath: 'debug/', // Debug data subdirectory +}) +``` + +Debug data is stored in `var/debug/` by default. + +## Debug Broker + +For distributed applications, configure a debug broker: + +```typescript +new FrameworkModule({ + debug: true, + debugBrokerHost: 'localhost:8812' // Separate broker for debug data +}) +``` + +## Production Considerations + +### Disable Debug in Production + +```typescript +const isProduction = process.env.NODE_ENV === 'production'; + +new FrameworkModule({ + debug: !isProduction, + profile: false // Disable profiling in production +}) +``` + +### Environment-Based Configuration + +```typescript +class AppConfig { + framework = { + debug: false, + profile: false + }; +} + +// Override via environment +// APP_FRAMEWORK_DEBUG=true +// APP_FRAMEWORK_PROFILE=true +``` + +## Performance Analysis + +### Analyzing Profiles + +Use the debug interface to analyze performance: + +1. **Request Timeline**: See the complete request lifecycle +2. **Database Queries**: Identify slow queries +3. **Memory Usage**: Track memory consumption +4. **Execution Time**: Find performance bottlenecks + +### Common Performance Issues + +1. **N+1 Query Problem**: Multiple database queries in loops +2. **Unindexed Queries**: Database queries without proper indexes +3. **Memory Leaks**: Objects not being garbage collected +4. **Blocking Operations**: Synchronous operations blocking the event loop + +## Debug CLI Commands + +The framework provides CLI commands for debugging: + +```bash +# Show application configuration +ts-node app.ts app:config + +# Show debug profile frames +ts-node app.ts debug:profile-frames + +# Show database information +ts-node app.ts database:info +``` + +## Custom Debug Controllers + +Create custom debug endpoints: + +```typescript +import { http } from '@deepkit/http'; + +@http.controller('/debug') +class DebugController { + @http.GET('/health') + getHealth() { + return { + status: 'ok', + memory: process.memoryUsage(), + uptime: process.uptime() + }; + } + + @http.GET('/metrics') + getMetrics() { + return { + // Custom application metrics + }; + } +} +``` + +## Testing with Debug Mode + +Test debug functionality: + +```typescript +import { createTestingApp } from '@deepkit/framework'; +import { Stopwatch } from '@deepkit/stopwatch'; + +test('profiling enabled', () => { + const testing = createTestingApp({ + imports: [new FrameworkModule({ debug: true })] + }); + + const stopwatch = testing.app.get(Stopwatch); + expect(stopwatch.active).toBe(true); +}); + +test('profiling disabled', () => { + const testing = createTestingApp({ + imports: [new FrameworkModule({ debug: false })] + }); + + const stopwatch = testing.app.get(Stopwatch); + expect(stopwatch.active).toBe(false); +}); +``` + +## Best Practices + +1. **Enable debug mode** only in development +2. **Use custom profiling** for critical operations +3. **Monitor memory usage** in long-running processes +4. **Analyze profiles regularly** to identify bottlenecks +5. **Clean up debug data** periodically +6. **Secure debug endpoints** in staging environments +7. **Use environment variables** for debug configuration +8. **Document performance requirements** for critical paths + +## Troubleshooting + +### Debug Interface Not Loading + +1. Check that debug mode is enabled +2. Verify the debug URL configuration +3. Ensure the server is running +4. Check for port conflicts + +### Profiling Data Missing + +1. Verify profiling is enabled +2. Check debug storage permissions +3. Ensure sufficient disk space +4. Verify debug broker connectivity + +### Performance Issues + +1. Check for memory leaks +2. Analyze database query patterns +3. Review profiling data +4. Monitor system resources + +## Next Steps + +- [Configuration](./configuration.md) - Debug configuration options +- [Database](./database.md) - Database debugging and profiling +- [Performance](../performance.md) - Performance optimization strategies +- [Monitoring](../monitoring.md) - Production monitoring and observability diff --git a/website/src/pages/documentation/framework/events.md b/website/src/pages/documentation/framework/events.md new file mode 100644 index 000000000..5f9b2f359 --- /dev/null +++ b/website/src/pages/documentation/framework/events.md @@ -0,0 +1,426 @@ +# Events and Lifecycle Hooks + +Deepkit Framework provides a comprehensive event system that allows you to hook into various application lifecycle events and create custom event-driven architectures. + +## Application Lifecycle Events + +The framework emits events during different phases of the application lifecycle: + +### Server Bootstrap Events + +```typescript +import { + onServerBootstrap, + onServerBootstrapDone, + onServerMainBootstrap, + onServerMainBootstrapDone, + onServerWorkerBootstrap, + onServerWorkerBootstrapDone +} from '@deepkit/framework'; +import { eventDispatcher } from '@deepkit/event'; + +class LifecycleListener { + @eventDispatcher.listen(onServerBootstrap) + onBootstrap() { + console.log('Server is bootstrapping...'); + // Initialize global resources + } + + @eventDispatcher.listen(onServerBootstrapDone) + onBootstrapDone() { + console.log('Server bootstrap completed'); + // Server is ready to handle requests + } + + @eventDispatcher.listen(onServerMainBootstrap) + onMainBootstrap() { + console.log('Main process bootstrapping...'); + // Master process initialization + } + + @eventDispatcher.listen(onServerWorkerBootstrap) + onWorkerBootstrap() { + console.log('Worker process bootstrapping...'); + // Worker process initialization + } +} +``` + +### Server Shutdown Events + +```typescript +import { onServerShutdown, onServerMainShutdown } from '@deepkit/framework'; + +class ShutdownListener { + @eventDispatcher.listen(onServerShutdown) + async onShutdown() { + console.log('Server is shutting down...'); + // Cleanup resources + await this.closeConnections(); + await this.saveState(); + } + + @eventDispatcher.listen(onServerMainShutdown) + async onMainShutdown() { + console.log('Main process shutting down...'); + // Master process cleanup + await this.shutdownWorkers(); + } + + private async closeConnections() { + // Close database connections, file handles, etc. + } + + private async saveState() { + // Save application state before shutdown + } + + private async shutdownWorkers() { + // Coordinate worker shutdown + } +} +``` + +## Database Events + +Listen to database lifecycle events: + +```typescript +import { DatabaseEvent } from '@deepkit/orm'; +import { eventDispatcher } from '@deepkit/event'; + +class DatabaseListener { + @eventDispatcher.listen(DatabaseEvent.onConnect) + onDatabaseConnect(event: DatabaseEvent) { + console.log(`Connected to database: ${event.database.name}`); + } + + @eventDispatcher.listen(DatabaseEvent.onDisconnect) + onDatabaseDisconnect(event: DatabaseEvent) { + console.log(`Disconnected from database: ${event.database.name}`); + } + + @eventDispatcher.listen(DatabaseEvent.onMigrate) + onDatabaseMigrate(event: DatabaseEvent) { + console.log(`Database migration completed: ${event.database.name}`); + } +} +``` + +## Custom Events + +Create and dispatch custom events: + +```typescript +import { BaseEvent, EventToken, eventDispatcher } from '@deepkit/event'; + +// Define custom event +class UserCreatedEvent extends BaseEvent { + constructor( + public readonly userId: number, + public readonly email: string + ) { + super(); + } +} + +// Create event token +export const onUserCreated = new EventToken('user.created', UserCreatedEvent); + +// Service that dispatches events +class UserService { + constructor(private eventDispatcher: EventDispatcher) {} + + async createUser(email: string): Promise { + const user = await this.saveUser(email); + + // Dispatch custom event + await this.eventDispatcher.dispatch(onUserCreated, new UserCreatedEvent(user.id, email)); + + return user; + } +} + +// Listener for custom events +class UserEventListener { + @eventDispatcher.listen(onUserCreated) + async onUserCreated(event: UserCreatedEvent) { + console.log(`User created: ${event.email}`); + + // Send welcome email + await this.sendWelcomeEmail(event.email); + + // Update analytics + await this.trackUserRegistration(event.userId); + } + + private async sendWelcomeEmail(email: string) { + // Email sending logic + } + + private async trackUserRegistration(userId: number) { + // Analytics tracking + } +} +``` + +## Event Data Events + +Use data events for type-safe event handling: + +```typescript +import { DataEvent, DataEventToken } from '@deepkit/event'; + +// Define data event +interface OrderData { + orderId: number; + userId: number; + amount: number; +} + +// Create data event token +export const onOrderPlaced = new DataEventToken('order.placed'); + +// Service that dispatches data events +class OrderService { + constructor(private eventDispatcher: EventDispatcher) {} + + async placeOrder(userId: number, amount: number): Promise { + const order = await this.saveOrder(userId, amount); + + // Dispatch data event + await this.eventDispatcher.dispatch(onOrderPlaced, { + orderId: order.id, + userId, + amount + }); + + return order; + } +} + +// Listener for data events +class OrderEventListener { + @eventDispatcher.listen(onOrderPlaced) + async onOrderPlaced(data: OrderData) { + console.log(`Order placed: ${data.orderId} for $${data.amount}`); + + // Process payment + await this.processPayment(data); + + // Send confirmation + await this.sendOrderConfirmation(data); + } +} +``` + +## Async Event Handling + +Handle events asynchronously: + +```typescript +class AsyncEventListener { + @eventDispatcher.listen(onUserCreated) + async onUserCreatedAsync(event: UserCreatedEvent) { + // This runs asynchronously + await this.performLongRunningTask(event.userId); + } + + @eventDispatcher.listen(onOrderPlaced) + async onOrderPlacedAsync(data: OrderData) { + // Multiple async operations + await Promise.all([ + this.updateInventory(data.orderId), + this.notifyWarehouse(data.orderId), + this.updateAnalytics(data) + ]); + } + + private async performLongRunningTask(userId: number) { + // Long-running operation that doesn't block the main flow + } +} +``` + +## Event Priority + +Control event listener execution order: + +```typescript +class PriorityEventListener { + @eventDispatcher.listen(onUserCreated, { priority: 100 }) + highPriorityHandler(event: UserCreatedEvent) { + // Runs first (higher priority) + console.log('High priority handler'); + } + + @eventDispatcher.listen(onUserCreated, { priority: 50 }) + mediumPriorityHandler(event: UserCreatedEvent) { + // Runs second + console.log('Medium priority handler'); + } + + @eventDispatcher.listen(onUserCreated, { priority: 10 }) + lowPriorityHandler(event: UserCreatedEvent) { + // Runs last (lower priority) + console.log('Low priority handler'); + } +} +``` + +## Error Handling in Events + +Handle errors in event listeners: + +```typescript +class ErrorHandlingListener { + @eventDispatcher.listen(onUserCreated) + async onUserCreated(event: UserCreatedEvent) { + try { + await this.sendWelcomeEmail(event.email); + } catch (error) { + console.error('Failed to send welcome email:', error); + // Don't let email failure break user creation + } + + try { + await this.setupUserProfile(event.userId); + } catch (error) { + console.error('Failed to setup user profile:', error); + // Log error but continue + } + } +} +``` + +## Conditional Event Listeners + +Create conditional event listeners: + +```typescript +class ConditionalListener { + @eventDispatcher.listen(onOrderPlaced) + async onLargeOrder(data: OrderData) { + // Only handle large orders + if (data.amount > 1000) { + await this.notifyManager(data); + await this.applyVipDiscount(data.orderId); + } + } + + @eventDispatcher.listen(onUserCreated) + async onPremiumUser(event: UserCreatedEvent) { + // Only handle premium users + if (await this.isPremiumUser(event.userId)) { + await this.setupPremiumFeatures(event.userId); + } + } +} +``` + +## Event Testing + +Test event dispatching and handling: + +```typescript +import { createTestingApp } from '@deepkit/framework'; +import { EventDispatcher } from '@deepkit/event'; + +test('user created event', async () => { + const testing = createTestingApp({ + providers: [UserService, UserEventListener] + }); + + const userService = testing.app.get(UserService); + const eventDispatcher = testing.app.get(EventDispatcher); + + // Track dispatched events + const dispatchedEvents: any[] = []; + const originalDispatch = eventDispatcher.dispatch; + eventDispatcher.dispatch = jest.fn(async (token, event) => { + dispatchedEvents.push({ token, event }); + return originalDispatch.call(eventDispatcher, token, event); + }); + + // Create user + await userService.createUser('test@example.com'); + + // Verify event was dispatched + expect(dispatchedEvents).toHaveLength(1); + expect(dispatchedEvents[0].token).toBe(onUserCreated); + expect(dispatchedEvents[0].event.email).toBe('test@example.com'); +}); +``` + +## Event-Driven Architecture Patterns + +### Saga Pattern + +```typescript +class OrderSaga { + @eventDispatcher.listen(onOrderPlaced) + async handleOrderPlaced(data: OrderData) { + try { + await this.reserveInventory(data.orderId); + await this.processPayment(data.orderId); + await this.shipOrder(data.orderId); + + // Dispatch success event + await this.eventDispatcher.dispatch(onOrderCompleted, data); + } catch (error) { + // Dispatch failure event and compensate + await this.eventDispatcher.dispatch(onOrderFailed, { ...data, error }); + await this.compensateOrder(data.orderId); + } + } +} +``` + +### Event Sourcing + +```typescript +class EventStore { + @eventDispatcher.listen(onUserCreated) + async storeUserCreatedEvent(event: UserCreatedEvent) { + await this.appendEvent('user', event.userId, 'created', { + email: event.email, + timestamp: new Date() + }); + } + + @eventDispatcher.listen(onOrderPlaced) + async storeOrderPlacedEvent(data: OrderData) { + await this.appendEvent('order', data.orderId, 'placed', data); + } + + private async appendEvent(aggregate: string, id: number, type: string, data: any) { + // Store event in event store + } +} +``` + +## Best Practices + +1. **Use descriptive event names** that clearly indicate what happened +2. **Keep event handlers focused** on a single responsibility +3. **Handle errors gracefully** to prevent cascading failures +4. **Use async handlers** for non-blocking operations +5. **Test event flows** thoroughly +6. **Document event contracts** for team collaboration +7. **Use priority** to control execution order when needed +8. **Avoid circular dependencies** between event handlers + +## Performance Considerations + +1. **Minimize event handler complexity** to avoid blocking +2. **Use async handlers** for I/O operations +3. **Consider event batching** for high-frequency events +4. **Monitor event processing time** in production +5. **Use conditional handlers** to reduce unnecessary processing + +## Next Steps + +- [Application Server](./application-server.md) - Server lifecycle management +- [Testing](./testing.md) - Testing event-driven code +- [Performance](../performance.md) - Event performance optimization +- [Architecture](../architecture.md) - Event-driven architecture patterns diff --git a/website/src/pages/documentation/framework/filesystem.md b/website/src/pages/documentation/framework/filesystem.md new file mode 100644 index 000000000..5c982bee9 --- /dev/null +++ b/website/src/pages/documentation/framework/filesystem.md @@ -0,0 +1,495 @@ +# Filesystem Integration + +Deepkit Framework provides a powerful filesystem abstraction that allows you to work with different storage backends through a unified interface. This enables easy switching between local filesystem, cloud storage, and in-memory storage. + +## Overview + +The filesystem integration provides: + +- **Unified API** for different storage backends +- **Named filesystems** for multiple storage systems +- **Dependency injection** integration +- **Testing support** with in-memory adapters +- **Type-safe** file operations + +## Basic Filesystem Usage + +### Default Filesystem Provider + +Set up a default filesystem: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; +import { Filesystem, FilesystemLocalAdapter, provideFilesystem } from '@deepkit/filesystem'; + +const app = new App({ + providers: [ + provideFilesystem(new FilesystemLocalAdapter('/path/to/storage')) + ], + imports: [new FrameworkModule()] +}); +``` + +### Using Filesystem in Services + +Inject and use the filesystem: + +```typescript +import { Filesystem } from '@deepkit/filesystem'; + +class FileService { + constructor(private fs: Filesystem) {} + + async saveFile(path: string, content: string): Promise { + await this.fs.write(path, content); + } + + async readFile(path: string): Promise { + return await this.fs.readAsText(path); + } + + async fileExists(path: string): Promise { + return await this.fs.exists(path); + } + + async deleteFile(path: string): Promise { + await this.fs.delete(path); + } + + async listFiles(directory: string): Promise { + const files = await this.fs.files(directory); + return files.map(file => file.path); + } +} +``` + +## Named Filesystems + +Use multiple filesystems for different purposes: + +```typescript +import { + provideNamedFilesystem, + NamedFilesystem, + FilesystemLocalAdapter, + FilesystemS3Adapter +} from '@deepkit/filesystem'; + +const app = new App({ + providers: [ + // Local filesystem for temporary files + provideNamedFilesystem('temp', new FilesystemLocalAdapter('/tmp')), + + // S3 for permanent storage + provideNamedFilesystem('storage', new FilesystemS3Adapter({ + bucket: 'my-bucket', + region: 'us-east-1' + })), + + // Local filesystem for uploads + provideNamedFilesystem('uploads', new FilesystemLocalAdapter('/uploads')) + ], + imports: [new FrameworkModule()] +}); +``` + +### Using Named Filesystems + +```typescript +import { NamedFilesystem } from '@deepkit/filesystem'; + +class DocumentService { + constructor( + @NamedFilesystem('storage') private storage: Filesystem, + @NamedFilesystem('temp') private temp: Filesystem + ) {} + + async processDocument(documentData: Buffer): Promise { + // Save to temporary storage first + const tempPath = `temp-${Date.now()}.pdf`; + await this.temp.write(tempPath, documentData); + + try { + // Process the document + const processedData = await this.processFile(tempPath); + + // Save to permanent storage + const permanentPath = `documents/${this.generateId()}.pdf`; + await this.storage.write(permanentPath, processedData); + + return permanentPath; + } finally { + // Clean up temporary file + await this.temp.delete(tempPath); + } + } + + async getDocument(path: string): Promise { + return await this.storage.read(path); + } +} +``` + +## File Operations + +### Reading Files + +```typescript +class FileReader { + constructor(private fs: Filesystem) {} + + async readTextFile(path: string): Promise { + return await this.fs.readAsText(path); + } + + async readBinaryFile(path: string): Promise { + return await this.fs.read(path); + } + + async readJsonFile(path: string): Promise { + const content = await this.fs.readAsText(path); + return JSON.parse(content); + } + + async streamFile(path: string): Promise { + return await this.fs.readAsStream(path); + } +} +``` + +### Writing Files + +```typescript +class FileWriter { + constructor(private fs: Filesystem) {} + + async writeTextFile(path: string, content: string): Promise { + await this.fs.write(path, content); + } + + async writeBinaryFile(path: string, data: Buffer): Promise { + await this.fs.write(path, data); + } + + async writeJsonFile(path: string, data: any): Promise { + const content = JSON.stringify(data, null, 2); + await this.fs.write(path, content); + } + + async appendToFile(path: string, content: string): Promise { + const existing = await this.fs.exists(path) + ? await this.fs.readAsText(path) + : ''; + await this.fs.write(path, existing + content); + } +} +``` + +### File Management + +```typescript +class FileManager { + constructor(private fs: Filesystem) {} + + async copyFile(source: string, destination: string): Promise { + const data = await this.fs.read(source); + await this.fs.write(destination, data); + } + + async moveFile(source: string, destination: string): Promise { + await this.copyFile(source, destination); + await this.fs.delete(source); + } + + async createDirectory(path: string): Promise { + // Most adapters handle directory creation automatically + await this.fs.write(`${path}/.keep`, ''); + } + + async getFileInfo(path: string) { + const file = await this.fs.get(path); + return { + path: file.path, + size: file.size, + lastModified: file.lastModified, + exists: await this.fs.exists(path) + }; + } + + async listDirectory(path: string) { + const files = await this.fs.files(path); + return files.map(file => ({ + name: file.name, + path: file.path, + size: file.size, + isDirectory: file.isDirectory, + lastModified: file.lastModified + })); + } +} +``` + +## File Upload Handling + +Handle file uploads with filesystem integration: + +```typescript +import { http, HttpFile } from '@deepkit/http'; + +class UploadController { + constructor( + @NamedFilesystem('uploads') private uploads: Filesystem + ) {} + + @http.POST('/upload') + async uploadFile(@http.body() file: HttpFile): Promise<{ path: string }> { + const filename = `${Date.now()}-${file.name}`; + const path = `uploads/${filename}`; + + await this.uploads.write(path, file.buffer); + + return { path }; + } + + @http.POST('/upload-multiple') + async uploadMultipleFiles(@http.body() files: HttpFile[]): Promise<{ paths: string[] }> { + const paths: string[] = []; + + for (const file of files) { + const filename = `${Date.now()}-${file.name}`; + const path = `uploads/${filename}`; + + await this.uploads.write(path, file.buffer); + paths.push(path); + } + + return { paths }; + } + + @http.GET('/download/:filename') + async downloadFile(@http.param() filename: string): Promise { + const path = `uploads/${filename}`; + + if (!await this.uploads.exists(path)) { + throw new Error('File not found'); + } + + return await this.uploads.read(path); + } +} +``` + +## Image Processing + +Combine filesystem with image processing: + +```typescript +import sharp from 'sharp'; + +class ImageService { + constructor( + @NamedFilesystem('images') private images: Filesystem + ) {} + + async processAndSaveImage(imageData: Buffer, filename: string): Promise { + const paths: string[] = []; + + // Save original + const originalPath = `original/${filename}`; + await this.images.write(originalPath, imageData); + paths.push(originalPath); + + // Create thumbnail + const thumbnailData = await sharp(imageData) + .resize(200, 200) + .jpeg({ quality: 80 }) + .toBuffer(); + + const thumbnailPath = `thumbnails/${filename}`; + await this.images.write(thumbnailPath, thumbnailData); + paths.push(thumbnailPath); + + // Create medium size + const mediumData = await sharp(imageData) + .resize(800, 600) + .jpeg({ quality: 90 }) + .toBuffer(); + + const mediumPath = `medium/${filename}`; + await this.images.write(mediumPath, mediumData); + paths.push(mediumPath); + + return paths; + } + + async getImageVariant(filename: string, variant: 'original' | 'thumbnail' | 'medium'): Promise { + const path = `${variant}/${filename}`; + return await this.images.read(path); + } +} +``` + +## Configuration Management + +Use filesystem for configuration files: + +```typescript +class ConfigManager { + constructor( + @NamedFilesystem('config') private configFs: Filesystem + ) {} + + async loadConfig(name: string): Promise { + const path = `${name}.json`; + + if (!await this.configFs.exists(path)) { + throw new Error(`Configuration file ${name} not found`); + } + + const content = await this.configFs.readAsText(path); + return JSON.parse(content); + } + + async saveConfig(name: string, config: any): Promise { + const path = `${name}.json`; + const content = JSON.stringify(config, null, 2); + await this.configFs.write(path, content); + } + + async listConfigs(): Promise { + const files = await this.configFs.files('.'); + return files + .filter(file => file.name.endsWith('.json')) + .map(file => file.name.replace('.json', '')); + } +} +``` + +## Testing with Filesystem + +Use in-memory filesystem for testing: + +```typescript +import { createTestingApp } from '@deepkit/framework'; +import { FilesystemMemoryAdapter, provideFilesystem } from '@deepkit/filesystem'; + +test('file operations', async () => { + const testing = createTestingApp({ + providers: [ + provideFilesystem(new FilesystemMemoryAdapter()), + FileService + ] + }); + + const fileService = testing.app.get(FileService); + + // Test file operations + await fileService.saveFile('test.txt', 'Hello World'); + + const content = await fileService.readFile('test.txt'); + expect(content).toBe('Hello World'); + + const exists = await fileService.fileExists('test.txt'); + expect(exists).toBe(true); + + await fileService.deleteFile('test.txt'); + + const existsAfterDelete = await fileService.fileExists('test.txt'); + expect(existsAfterDelete).toBe(false); +}); + +test('named filesystems', async () => { + const testing = createTestingApp({ + providers: [ + provideNamedFilesystem('temp', new FilesystemMemoryAdapter()), + provideNamedFilesystem('storage', new FilesystemMemoryAdapter()), + DocumentService + ] + }); + + const documentService = testing.app.get(DocumentService); + + const documentData = Buffer.from('PDF content'); + const path = await documentService.processDocument(documentData); + + expect(path).toMatch(/^documents\/.*\.pdf$/); + + const retrievedData = await documentService.getDocument(path); + expect(retrievedData).toEqual(documentData); +}); +``` + +## Error Handling + +Handle filesystem errors gracefully: + +```typescript +class RobustFileService { + constructor(private fs: Filesystem) {} + + async safeReadFile(path: string): Promise { + try { + return await this.fs.readAsText(path); + } catch (error) { + if (error.code === 'ENOENT') { + return null; // File not found + } + throw error; // Re-throw other errors + } + } + + async safeWriteFile(path: string, content: string): Promise { + try { + await this.fs.write(path, content); + return true; + } catch (error) { + console.error(`Failed to write file ${path}:`, error); + return false; + } + } + + async retryOperation(operation: () => Promise, maxRetries: number = 3): Promise { + let lastError: Error; + + for (let i = 0; i < maxRetries; i++) { + try { + return await operation(); + } catch (error) { + lastError = error as Error; + if (i < maxRetries - 1) { + await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); + } + } + } + + throw lastError!; + } +} +``` + +## Best Practices + +1. **Use named filesystems** for different storage purposes +2. **Handle errors gracefully** with try-catch blocks +3. **Use in-memory adapters** for testing +4. **Clean up temporary files** after processing +5. **Validate file paths** to prevent directory traversal +6. **Use appropriate adapters** for your storage needs +7. **Monitor storage usage** and implement cleanup policies +8. **Implement retry logic** for network-based storage + +## Available Adapters + +- **FilesystemLocalAdapter**: Local filesystem storage +- **FilesystemMemoryAdapter**: In-memory storage (testing) +- **FilesystemS3Adapter**: Amazon S3 storage +- **FilesystemGoogleCloudAdapter**: Google Cloud Storage +- **FilesystemAzureAdapter**: Azure Blob Storage + +## Next Steps + +- [Configuration](./configuration.md) - Filesystem configuration +- [Testing](./testing.md) - Testing filesystem operations +- [Deployment](./deployment.md) - Production filesystem setup +- [Performance](../performance.md) - Filesystem performance optimization diff --git a/website/src/pages/documentation/framework/getting-started.md b/website/src/pages/documentation/framework/getting-started.md new file mode 100644 index 000000000..d0eb052d1 --- /dev/null +++ b/website/src/pages/documentation/framework/getting-started.md @@ -0,0 +1,208 @@ +# Getting Started + +Deepkit Framework is a highly modular, scalable, and fast TypeScript framework for building web applications, APIs, and microservices. This guide will help you get started with the framework quickly. + +## Installation + +First, make sure you have [Deepkit App](../app.md) installed. Then install the framework package: + +```bash +npm install @deepkit/framework +``` + +## Basic Application + +Create a simple application with HTTP and RPC support: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; +import { http } from '@deepkit/http'; +import { rpc } from '@deepkit/rpc'; + +class MyController { + @http.GET('/') + httpHello() { + return 'Hello from HTTP!'; + } +} + +@rpc.controller('main') +class MyRpcController { + @rpc.action() + rpcHello() { + return 'Hello from RPC!'; + } +} + +const app = new App({ + controllers: [MyController, MyRpcController], + imports: [new FrameworkModule()] +}); + +app.run(); +``` + +## Starting the Server + +Run your application: + +```bash +ts-node app.ts server:start +``` + +You'll see output like: + +``` +Start server ... +HTTP MyController + GET / httpHello +RPC MyRpcController main + rpcHello +HTTP listening at http://localhost:8080/ +Server started. +``` + +Your HTTP endpoint is available at `http://localhost:8080/` and RPC is available via WebSocket. + +## Framework Module Configuration + +The `FrameworkModule` accepts many configuration options: + +```typescript +new FrameworkModule({ + // Server configuration + host: '0.0.0.0', + port: 8080, + + // Enable debugging and profiling + debug: true, + profile: true, + + // SSL configuration + ssl: false, + selfSigned: false, // For development + + // Worker processes + workers: 0, // 0 = single process + + // Public directory for static files + publicDir: 'public', + + // Database migration + migrateOnStartup: false, + + // Logging + httpLog: true, + logStartup: true +}) +``` + +## Services and Dependency Injection + +Create services that can be injected into controllers: + +```typescript +class DatabaseService { + async getUsers() { + return [{ id: 1, name: 'John' }]; + } +} + +class UserController { + constructor(private db: DatabaseService) {} + + @http.GET('/users') + async getUsers() { + return await this.db.getUsers(); + } +} + +const app = new App({ + providers: [DatabaseService], + controllers: [UserController], + imports: [new FrameworkModule()] +}); +``` + +## Configuration Management + +Use configuration classes for environment-specific settings: + +```typescript +class AppConfig { + host: string = 'localhost'; + port: number = 8080; + databaseUrl: string = 'sqlite://app.db'; +} + +const app = new App({ + config: AppConfig, + controllers: [UserController], + imports: [new FrameworkModule()] +}); + +// Load from environment variables +app.loadConfigFromEnv({ prefix: 'APP_' }); +app.run(); +``` + +Set environment variables: + +```bash +APP_HOST=0.0.0.0 APP_PORT=3000 ts-node app.ts server:start +``` + +## Available Commands + +The framework provides several built-in commands: + +```bash +# Start the server +ts-node app.ts server:start + +# Show application configuration +ts-node app.ts app:config + +# Database migrations +ts-node app.ts migration:create +ts-node app.ts migration:up +ts-node app.ts migration:down + +# Show all available commands +ts-node app.ts +``` + +## Development Features + +### Debug Mode + +Enable debug mode to access the web-based debugger: + +```typescript +new FrameworkModule({ debug: true }) +``` + +Visit `http://localhost:8080/_debug` to access: +- Configuration viewer +- Database browser +- Profiler +- Route inspector + +### Hot Reloading + +Use `ts-node-dev` for automatic restarts during development: + +```bash +npm install -D ts-node-dev +ts-node-dev app.ts server:start +``` + +## Next Steps + +- [Configuration](./configuration.md) - Complete configuration reference +- [HTTP Controllers](../http.md) - Building REST APIs +- [RPC Controllers](./rpc.md) - Real-time communication +- [Database](./database.md) - Database integration with Deepkit ORM +- [Testing](./testing.md) - Testing your application +- [Deployment](./deployment.md) - Production deployment diff --git a/website/src/pages/documentation/framework/rpc.md b/website/src/pages/documentation/framework/rpc.md new file mode 100644 index 000000000..9eb28ddb2 --- /dev/null +++ b/website/src/pages/documentation/framework/rpc.md @@ -0,0 +1,387 @@ +# RPC (Remote Procedure Call) + +Deepkit Framework provides a powerful RPC system that allows real-time communication between client and server using WebSockets. RPC controllers enable you to build APIs that feel like local function calls. + +## Basic RPC Controller + +Create an RPC controller using the `@rpc.controller` decorator: + +```typescript +import { rpc } from '@deepkit/rpc'; + +@rpc.controller('main') +class MainController { + @rpc.action() + hello(name: string): string { + return `Hello ${name}!`; + } + + @rpc.action() + async getUser(id: number): Promise { + // Async operations work seamlessly + return await this.userService.findById(id); + } +} +``` + +Register the controller in your app: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; + +const app = new App({ + controllers: [MainController], + imports: [new FrameworkModule()] +}); +``` + +## RPC Client Usage + +### TypeScript Client + +```typescript +import { RpcClient } from '@deepkit/rpc'; + +// Create client +const client = new RpcClient('ws://localhost:8080'); +await client.connect(); + +// Get controller proxy +const controller = client.controller('main'); + +// Call methods as if they were local +const result = await controller.hello('World'); +console.log(result); // "Hello World!" + +// Type safety is preserved +const user = await controller.getUser(123); +``` + +### JavaScript Client + +```javascript +import { RpcClient } from '@deepkit/rpc'; + +const client = new RpcClient('ws://localhost:8080'); +await client.connect(); + +const controller = client.controller('main'); +const result = await controller.hello('World'); +``` + +## Dependency Injection in RPC Controllers + +RPC controllers support full dependency injection: + +```typescript +class UserService { + async findById(id: number): Promise { + // Database logic + } +} + +@rpc.controller('users') +class UserController { + constructor( + private userService: UserService, + private logger: Logger + ) {} + + @rpc.action() + async getUser(id: number): Promise { + this.logger.log(`Getting user ${id}`); + return await this.userService.findById(id); + } +} + +const app = new App({ + providers: [UserService], + controllers: [UserController], + imports: [new FrameworkModule()] +}); +``` + +## RPC Context and Session + +Access request context and session information: + +```typescript +import { RpcKernelConnection, SessionState } from '@deepkit/rpc'; + +@rpc.controller('auth') +class AuthController { + constructor( + private connection: RpcKernelConnection, + private sessionState: SessionState + ) {} + + @rpc.action() + getCurrentUser() { + const session = this.sessionState.getSession(); + return session.username; + } + + @rpc.action() + getConnectionInfo() { + return { + id: this.connection.id, + remoteAddress: this.connection.remoteAddress + }; + } +} +``` + +## Authentication and Security + +Implement RPC authentication: + +```typescript +import { RpcKernelSecurity, Session } from '@deepkit/rpc'; + +class MyRpcSecurity extends RpcKernelSecurity { + async authenticate(token: any): Promise { + if (token === 'valid-token') { + return new Session('user123', token); + } + throw new Error('Invalid token'); + } +} + +const app = new App({ + providers: [ + { provide: RpcKernelSecurity, useClass: MyRpcSecurity } + ], + controllers: [AuthController], + imports: [new FrameworkModule()] +}); +``` + +Client authentication: + +```typescript +const client = new RpcClient('ws://localhost:8080'); +await client.connect(); + +// Authenticate +await client.authenticate('valid-token'); + +// Now authenticated calls work +const controller = client.controller('auth'); +const user = await controller.getCurrentUser(); +``` + +## Error Handling + +Handle errors in RPC methods: + +```typescript +@rpc.controller('api') +class ApiController { + @rpc.action() + async riskyOperation(data: any) { + try { + return await this.processData(data); + } catch (error) { + // Errors are automatically serialized and sent to client + throw new Error(`Processing failed: ${error.message}`); + } + } +} +``` + +Client error handling: + +```typescript +try { + await controller.riskyOperation(invalidData); +} catch (error) { + console.error('RPC call failed:', error.message); +} +``` + +## Streaming and Observables + +RPC supports streaming data using RxJS observables: + +```typescript +import { Observable, interval } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@rpc.controller('stream') +class StreamController { + @rpc.action() + getTimeStream(): Observable { + return interval(1000).pipe( + map(() => new Date().toISOString()) + ); + } + + @rpc.action() + async *generateNumbers(count: number) { + for (let i = 0; i < count; i++) { + yield i; + await new Promise(resolve => setTimeout(resolve, 100)); + } + } +} +``` + +Client streaming consumption: + +```typescript +const controller = client.controller('stream'); + +// Observable stream +const timeStream = await controller.getTimeStream(); +timeStream.subscribe(time => { + console.log('Current time:', time); +}); + +// Async generator +const numbers = await controller.generateNumbers(10); +for await (const number of numbers) { + console.log('Number:', number); +} +``` + +## HTTP-based RPC + +Enable RPC calls over HTTP for easier integration: + +```typescript +new FrameworkModule({ + httpRpcBasePath: '/rpc/v1' +}) +``` + +Now RPC methods are available via HTTP POST: + +```bash +curl -X POST http://localhost:8080/rpc/v1/main/hello \ + -H "Content-Type: application/json" \ + -d '{"name": "World"}' +``` + +## Testing RPC Controllers + +Use the testing utilities for RPC testing: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +test('rpc controller', async () => { + const testing = createTestingApp({ + controllers: [MainController] + }); + + await testing.startServer(); + + const client = testing.createRpcClient(); + const controller = client.controller('main'); + + const result = await controller.hello('Test'); + expect(result).toBe('Hello Test!'); + + await testing.stopServer(); +}); +``` + +## RPC Controller Organization + +Organize controllers by feature: + +```typescript +// User management +@rpc.controller('users') +class UserController { + @rpc.action() + async create(userData: CreateUserData) { } + + @rpc.action() + async update(id: number, data: UpdateUserData) { } +} + +// File operations +@rpc.controller('files') +class FileController { + @rpc.action() + async upload(file: FileData) { } + + @rpc.action() + async download(id: string) { } +} + +// Real-time notifications +@rpc.controller('notifications') +class NotificationController { + @rpc.action() + getNotificationStream(): Observable { + return this.notificationService.stream(); + } +} +``` + +## Advanced RPC Features + +### Custom Controller Names + +Use symbols for type-safe controller references: + +```typescript +import { ControllerSymbol } from '@deepkit/rpc'; + +const UserControllerSymbol = ControllerSymbol('users'); + +@rpc.controller(UserControllerSymbol) +class UserController { + // Implementation +} + +// Type-safe client usage +const controller = client.controller(UserControllerSymbol); +``` + +### RPC Modules + +Organize RPC controllers in modules: + +```typescript +import { AppModule } from '@deepkit/app'; + +const UserModule = new AppModule({ + controllers: [UserController], + providers: [UserService] +}, 'user'); + +const app = new App({ + imports: [ + UserModule, + new FrameworkModule() + ] +}); +``` + +## Performance Considerations + +1. **Connection Pooling**: Reuse RPC clients when possible +2. **Streaming**: Use observables for large datasets +3. **Batching**: Group related RPC calls +4. **Caching**: Cache frequently accessed data +5. **Error Handling**: Implement proper error boundaries + +## Best Practices + +1. **Type Safety**: Use TypeScript interfaces for RPC methods +2. **Authentication**: Implement proper security for sensitive operations +3. **Error Handling**: Provide meaningful error messages +4. **Testing**: Write comprehensive tests for RPC controllers +5. **Documentation**: Document RPC APIs for client developers +6. **Versioning**: Plan for API versioning from the start + +## Next Steps + +- [Authentication](../auth.md) - Implementing authentication +- [Testing](./testing.md) - Testing RPC functionality +- [WebSockets](../websocket.md) - Advanced WebSocket features +- [Real-time Features](../realtime.md) - Building real-time applications diff --git a/website/src/pages/documentation/framework/testing.md b/website/src/pages/documentation/framework/testing.md index e3717c0ac..2420f4641 100644 --- a/website/src/pages/documentation/framework/testing.md +++ b/website/src/pages/documentation/framework/testing.md @@ -2,18 +2,25 @@ The services and controllers in the Deepkit framework are designed to support SOLID and clean code that is well-designed, encapsulated, and separated. These features make the code easy to test. -This documentation shows you how to set up a testing framework named [Jest](https://jestjs.io) with `ts-jest`. To do this, run the following command to install `jest` and `ts-jest`. +This documentation shows you how to set up a testing framework named [Jest](https://jestjs.io) with `ts-jest` and covers comprehensive testing patterns for Deepkit Framework applications. + +## Setup + +Install Jest and TypeScript support: ```sh -npm install jest ts-jest @types/jest +npm install jest ts-jest @types/jest --save-dev ``` -Jest needs a few configuration options to know where to find the test suits and how to compile the TS code. Add the following configuration to your `package.json`: +Configure Jest in your `package.json`: ```json title=package.json { - ..., - + "scripts": { + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" + }, "jest": { "transform": { "^.+\\.(ts|tsx)$": "ts-jest" @@ -21,6 +28,10 @@ Jest needs a few configuration options to know where to find the test suits and "testEnvironment": "node", "testMatch": [ "**/*.spec.ts" + ], + "collectCoverageFrom": [ + "src/**/*.ts", + "!src/**/*.d.ts" ] } } @@ -50,33 +61,90 @@ Ran all test suites. Please read the [Jest-Dokumentation](https://jestjs.io) to learn more about how the Jest CLI tool works and how you can write more sophisticated tests and entire test suites. -## Unit Test +## Unit Tests -Whenever possible, you should unit test your services. The simpler, better separated, and better defined your service dependencies are, the easier it is to test them. In this case, you can write simple tests like the following: +Unit tests are the simplest form of testing. They test a single unit of code, such as a function or a class, in isolation. Since Deepkit's dependency injection container is very powerful, it's easy to test individual services and controllers. + +### Testing Services + +Test services by instantiating them directly: ```typescript export class MyService { helloWorld() { return 'hello world'; } + + async calculateAsync(value: number): Promise { + return value * 2; + } } ``` ```typescript -// +import { expect, test } from '@jest/globals'; import { MyService } from './my-service.ts'; test('hello world', () => { const myService = new MyService(); expect(myService.helloWorld()).toBe('hello world'); }); + +test('async calculation', async () => { + const myService = new MyService(); + const result = await myService.calculateAsync(5); + expect(result).toBe(10); +}); ``` -## Integration tests +### Testing Services with Dependencies -It's not always possible to write unit tests, nor is it always the most efficient way to cover business-critical code and behavior. Especially if your architecture is very complex, it is beneficial to be able to easily perform end-to-end integration tests. +Use mocks for service dependencies: -As you have already learned in the Dependency Injection chapter, the Dependency Injection Container is the heart of Deepkit. This is where all services are built and run. Your application defines services (providers), controllers, listeners, and imports. For integration testing, you don't necessarily want to have all services available in a test case, but you usually want to have a stripped down version of the application available to test the critical areas. +```typescript +class DatabaseService { + async findUser(id: number) { + // Database logic + } +} + +class UserService { + constructor(private db: DatabaseService) {} + + async getUser(id: number) { + const user = await this.db.findUser(id); + return user ? { ...user, active: true } : null; + } +} + +test('user service with mock', async () => { + const mockDb = { + findUser: jest.fn().mockResolvedValue({ id: 1, name: 'John' }) + } as any; + + const userService = new UserService(mockDb); + const user = await userService.getUser(1); + + expect(user).toEqual({ id: 1, name: 'John', active: true }); + expect(mockDb.findUser).toHaveBeenCalledWith(1); +}); +``` + +## Integration Tests + +Integration tests verify that multiple components work together correctly. Deepkit Framework provides powerful testing utilities that make it easy to test your entire application or specific modules. + +The `createTestingApp` function creates a test application with in-memory services, making tests fast and isolated. It automatically configures: + +- In-memory HTTP server (no TCP stack) +- In-memory RPC communication +- Memory-based logger +- Memory-based broker +- Memory-based database (if entities provided) + +### Basic Integration Testing + +Use `createTestingApp` to create a test application: ```typescript import { createTestingApp } from '@deepkit/framework'; @@ -177,3 +245,345 @@ test('service simple big', async () => { expect(myService.doIt()).toBe(true); }); ``` + +## HTTP Controller Testing + +Test HTTP controllers using the testing utilities: + +```typescript +import { createTestingApp } from '@deepkit/framework'; +import { http, HttpRequest } from '@deepkit/http'; + +test('http controller', async () => { + class MyController { + @http.GET('/users/:id') + getUser(@http.param() id: number) { + return { id, name: `User ${id}` }; + } + + @http.POST('/users') + createUser(@http.body() userData: any) { + return { id: 123, ...userData }; + } + } + + const testing = createTestingApp({ controllers: [MyController] }); + await testing.startServer(); + + try { + // Test GET request + const getResponse = await testing.request(HttpRequest.GET('/users/42')); + expect(getResponse.statusCode).toBe(200); + expect(getResponse.json).toEqual({ id: 42, name: 'User 42' }); + + // Test POST request + const postResponse = await testing.request( + HttpRequest.POST('/users').json({ name: 'John', email: 'john@example.com' }) + ); + expect(postResponse.statusCode).toBe(200); + expect(postResponse.json).toEqual({ + id: 123, + name: 'John', + email: 'john@example.com' + }); + } finally { + await testing.stopServer(); + } +}); +``` + +## RPC Controller Testing + +Test RPC controllers with full type safety: + +```typescript +import { createTestingApp } from '@deepkit/framework'; +import { rpc } from '@deepkit/rpc'; + +test('rpc controller', async () => { + @rpc.controller('main') + class MainController { + @rpc.action() + hello(name: string): string { + return `Hello ${name}!`; + } + + @rpc.action() + async getUser(id: number): Promise<{ id: number; name: string }> { + return { id, name: `User ${id}` }; + } + } + + const testing = createTestingApp({ controllers: [MainController] }); + await testing.startServer(); + + try { + const client = testing.createRpcClient(); + const controller = client.controller('main'); + + const greeting = await controller.hello('World'); + expect(greeting).toBe('Hello World!'); + + const user = await controller.getUser(123); + expect(user).toEqual({ id: 123, name: 'User 123' }); + } finally { + await testing.stopServer(); + } +}); +``` + +## Database Testing + +Test with in-memory database for fast, isolated tests: + +```typescript +import { createTestingApp } from '@deepkit/framework'; +import { entity, PrimaryKey } from '@deepkit/type'; +import { Database } from '@deepkit/orm'; + +@entity.name('user') +class User { + id: PrimaryKey = 0; + name: string = ''; + email: string = ''; +} + +test('database operations', async () => { + const testing = createTestingApp({}, [User]); + + const database = testing.app.get(Database); + + // Create user + const user = new User(); + user.name = 'John'; + user.email = 'john@example.com'; + + await database.persist(user); + expect(user.id).toBeGreaterThan(0); + + // Query user + const foundUser = await database.query(User).filter({ name: 'John' }).findOne(); + expect(foundUser).toBeDefined(); + expect(foundUser!.email).toBe('john@example.com'); + + // Count users + const count = await database.query(User).count(); + expect(count).toBe(1); +}); +``` + +## Testing with Dependencies + +Test controllers and services with complex dependencies: + +```typescript +import { createTestingApp } from '@deepkit/framework'; +import { Logger } from '@deepkit/logger'; + +class UserService { + constructor(private logger: Logger) {} + + async createUser(name: string) { + this.logger.log(`Creating user: ${name}`); + return { id: 1, name }; + } +} + +@rpc.controller('users') +class UserController { + constructor(private userService: UserService) {} + + @rpc.action() + async create(name: string) { + return await this.userService.createUser(name); + } +} + +test('controller with dependencies', async () => { + const testing = createTestingApp({ + providers: [UserService], + controllers: [UserController] + }); + + await testing.startServer(); + + try { + const client = testing.createRpcClient(); + const controller = client.controller('users'); + + const user = await controller.create('John'); + expect(user).toEqual({ id: 1, name: 'John' }); + + // Check logs + const logger = testing.getLogger(); + expect(logger.messageStrings).toContain('Creating user: John'); + } finally { + await testing.stopServer(); + } +}); +``` + +## Testing Authentication + +Test RPC authentication and security: + +```typescript +import { createTestingApp } from '@deepkit/framework'; +import { RpcKernelSecurity, Session } from '@deepkit/rpc'; + +class TestRpcSecurity extends RpcKernelSecurity { + async authenticate(token: any): Promise { + if (token === 'valid-token') { + return new Session('user123', token); + } + throw new Error('Invalid token'); + } +} + +@rpc.controller('secure') +class SecureController { + @rpc.action() + getSecretData() { + return { secret: 'classified' }; + } +} + +test('rpc authentication', async () => { + const testing = createTestingApp({ + providers: [ + { provide: RpcKernelSecurity, useClass: TestRpcSecurity } + ], + controllers: [SecureController] + }); + + await testing.startServer(); + + try { + const client = testing.createRpcClient(); + + // Test without authentication - should fail + const controller = client.controller('secure'); + await expect(controller.getSecretData()).rejects.toThrow(); + + // Test with valid token + await client.authenticate('valid-token'); + const data = await controller.getSecretData(); + expect(data).toEqual({ secret: 'classified' }); + } finally { + await testing.stopServer(); + } +}); +``` + +## Testing Broker Functionality + +Test message broker features: + +```typescript +import { createTestingApp } from '@deepkit/framework'; +import { BrokerBus, BrokerCache } from '@deepkit/broker'; + +test('broker messaging', async () => { + const testing = createTestingApp({}); + + const bus = testing.app.get(BrokerBus); + const cache = testing.app.get(BrokerCache); + + // Test pub/sub + const messages: any[] = []; + const subscription = await bus.subscribe('test.channel'); + subscription.subscribe(msg => messages.push(msg)); + + await bus.publish('test.channel', { content: 'Hello World' }); + + // Wait for message processing + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('Hello World'); + + // Test cache + await cache.set('test-key', { data: 'cached' }, 60); + const cached = await cache.get('test-key'); + expect(cached).toEqual({ data: 'cached' }); +}); +``` + +## Testing Error Handling + +Test error scenarios and edge cases: + +```typescript +test('error handling', async () => { + @rpc.controller('error-test') + class ErrorController { + @rpc.action() + throwError() { + throw new Error('Something went wrong'); + } + + @rpc.action() + async asyncError() { + throw new Error('Async error'); + } + } + + const testing = createTestingApp({ controllers: [ErrorController] }); + await testing.startServer(); + + try { + const client = testing.createRpcClient(); + const controller = client.controller('error-test'); + + await expect(controller.throwError()).rejects.toThrow('Something went wrong'); + await expect(controller.asyncError()).rejects.toThrow('Async error'); + } finally { + await testing.stopServer(); + } +}); +``` + +## Best Practices + +1. **Use `createTestingApp`** for integration tests +2. **Mock external dependencies** in unit tests +3. **Test both success and error scenarios** +4. **Use in-memory services** for fast tests +5. **Clean up resources** with try/finally blocks +6. **Test authentication and authorization** +7. **Verify logs and side effects** +8. **Use type-safe RPC testing** +9. **Test database operations** with entities +10. **Cover edge cases and error conditions** + +## Test Organization + +Organize your tests by feature: + +``` +tests/ +├── unit/ +│ ├── services/ +│ │ ├── user.service.spec.ts +│ │ └── email.service.spec.ts +│ └── utils/ +│ └── helpers.spec.ts +├── integration/ +│ ├── http/ +│ │ ├── user.controller.spec.ts +│ │ └── auth.controller.spec.ts +│ ├── rpc/ +│ │ ├── user.rpc.spec.ts +│ │ └── notification.rpc.spec.ts +│ └── database/ +│ └── user.repository.spec.ts +└── e2e/ + └── user-workflow.spec.ts +``` + +## Next Steps + +- [Debugging](./debugging-profiling.md) - Debug and profile your tests +- [Database](./database.md) - Advanced database testing +- [RPC](./rpc.md) - Advanced RPC testing patterns +- [Performance](../performance.md) - Performance testing strategies diff --git a/website/src/pages/documentation/framework/workers.md b/website/src/pages/documentation/framework/workers.md new file mode 100644 index 000000000..0cbdc3cee --- /dev/null +++ b/website/src/pages/documentation/framework/workers.md @@ -0,0 +1,430 @@ +# Workers and Multi-Process Architecture + +Deepkit Framework supports multi-process architecture using worker processes to handle increased load and improve application performance. This chapter covers how to configure and work with workers. + +## Overview + +The framework can run in two modes: + +1. **Single Process**: All requests handled by the main process (default) +2. **Multi-Process**: Main process manages worker processes that handle requests + +## Basic Worker Configuration + +Configure workers in the FrameworkModule: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; + +const app = new App({ + imports: [ + new FrameworkModule({ + workers: 4 // Use 4 worker processes + }) + ] +}); +``` + +## How Workers Work + +### Master Process +- Manages worker processes +- Handles process coordination +- Distributes incoming connections +- Monitors worker health +- Restarts failed workers + +### Worker Processes +- Handle HTTP/RPC requests +- Execute application logic +- Process database operations +- Run background tasks + +## Worker Lifecycle + +### Starting Workers + +When you start the server with workers configured: + +```bash +ts-node app.ts server:start +``` + +The framework will: +1. Start the master process +2. Fork the specified number of worker processes +3. Set up load balancing between workers +4. Begin accepting connections + +### Worker Management + +Workers are automatically managed: + +```typescript +// Workers are automatically restarted if they crash +// Load is automatically distributed among healthy workers +// Graceful shutdown waits for all workers to finish +``` + +## Detecting Process Type + +Determine if code is running in master or worker: + +```typescript +import cluster from 'cluster'; + +class ProcessAwareService { + getProcessInfo() { + return { + isMaster: cluster.isMaster, + isWorker: cluster.isWorker, + workerId: cluster.worker?.id, + pid: process.pid + }; + } + + async initialize() { + if (cluster.isMaster) { + console.log('Initializing master process'); + // Master-only initialization + } else { + console.log(`Initializing worker ${cluster.worker.id}`); + // Worker-only initialization + } + } +} +``` + +## Worker-Specific Logic + +Run different logic in master vs worker processes: + +```typescript +import { onServerMainBootstrap, onServerWorkerBootstrap } from '@deepkit/framework'; + +class WorkerCoordinator { + @eventDispatcher.listen(onServerMainBootstrap) + onMasterBootstrap() { + console.log('Master process starting...'); + // Set up master-only services + this.setupMasterServices(); + } + + @eventDispatcher.listen(onServerWorkerBootstrap) + onWorkerBootstrap() { + console.log(`Worker ${cluster.worker.id} starting...`); + // Set up worker-only services + this.setupWorkerServices(); + } + + private setupMasterServices() { + // Services that should only run in master + // - Scheduled tasks + // - Monitoring + // - Admin interfaces + } + + private setupWorkerServices() { + // Services that run in each worker + // - Request handlers + // - Business logic + // - Database connections + } +} +``` + +## Inter-Process Communication + +Use the broker for communication between processes: + +```typescript +import { BrokerBus } from '@deepkit/broker'; + +class TaskCoordinator { + constructor(private bus: BrokerBus) {} + + async distributeWork() { + if (cluster.isMaster) { + // Master distributes work + await this.bus.publish('work.available', { + taskId: 'task-123', + data: { /* task data */ } + }); + } + } + + async setupWorkerListener() { + if (cluster.isWorker) { + // Workers listen for work + const subscription = await this.bus.subscribe('work.available'); + subscription.subscribe(async (message) => { + await this.processTask(message.data); + + // Report completion + await this.bus.publish('work.completed', { + taskId: message.taskId, + workerId: cluster.worker.id + }); + }); + } + } +} +``` + +## Shared State Management + +### Using Broker for Shared State + +```typescript +import { BrokerCache, BrokerKeyValue } from '@deepkit/broker'; + +class SharedStateService { + constructor( + private cache: BrokerCache, + private kv: BrokerKeyValue + ) {} + + async incrementCounter(key: string): Promise { + const current = await this.kv.get(key) || 0; + const newValue = current + 1; + await this.kv.set(key, newValue); + return newValue; + } + + async cacheUserSession(userId: string, session: any) { + await this.cache.set(`session:${userId}`, session, 3600); + } + + async getUserSession(userId: string) { + return await this.cache.get(`session:${userId}`); + } +} +``` + +### Database Connections + +Each worker maintains its own database connections: + +```typescript +class DatabaseService { + constructor(private database: Database) {} + + async getConnection() { + // Each worker has its own connection pool + return this.database.getConnection(); + } +} +``` + +## Worker Configuration + +### Optimal Worker Count + +```typescript +import os from 'os'; + +const cpuCount = os.cpus().length; + +new FrameworkModule({ + // Common configurations: + workers: cpuCount, // One worker per CPU core + workers: cpuCount - 1, // Leave one core for master + workers: Math.max(2, cpuCount / 2) // Half the cores +}) +``` + +### Environment-Based Configuration + +```typescript +class AppConfig { + framework = { + workers: parseInt(process.env.WORKERS || '0') + }; +} + +// Set via environment: +// WORKERS=4 ts-node app.ts server:start +``` + +## Graceful Shutdown + +Workers support graceful shutdown: + +```typescript +new FrameworkModule({ + workers: 4, + gracefulShutdownTimeout: 30 // 30 seconds for graceful shutdown +}) +``` + +### Handling Shutdown in Workers + +```typescript +import { onServerShutdown } from '@deepkit/framework'; + +class GracefulShutdownHandler { + @eventDispatcher.listen(onServerShutdown) + async onShutdown() { + console.log(`Worker ${cluster.worker?.id} shutting down...`); + + // Clean up resources + await this.closeConnections(); + await this.finishPendingTasks(); + } + + private async closeConnections() { + // Close database connections, file handles, etc. + } + + private async finishPendingTasks() { + // Complete ongoing operations + } +} +``` + +## Load Balancing + +The framework automatically load balances requests across workers: + +```typescript +// Requests are automatically distributed using round-robin +// No additional configuration needed +``` + +## Monitoring Workers + +Monitor worker health and performance: + +```typescript +class WorkerMonitor { + @eventDispatcher.listen(onServerMainBootstrap) + setupMonitoring() { + if (cluster.isMaster) { + cluster.on('online', (worker) => { + console.log(`Worker ${worker.id} is online`); + }); + + cluster.on('exit', (worker, code, signal) => { + console.log(`Worker ${worker.id} died with code ${code}`); + // Worker is automatically restarted + }); + + // Monitor worker memory usage + setInterval(() => { + for (const worker of Object.values(cluster.workers || {})) { + if (worker) { + worker.send('memory-check'); + } + } + }, 60000); // Every minute + } + } +} +``` + +## Testing with Workers + +Test worker functionality: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +test('single process testing', async () => { + // Testing app always runs in single process mode + const testing = createTestingApp({ + imports: [new FrameworkModule({ workers: 0 })] + }); + + // Test your application logic +}); + +test('worker configuration', () => { + const app = new App({ + imports: [new FrameworkModule({ workers: 4 })] + }); + + // Test configuration is applied correctly +}); +``` + +## Best Practices + +1. **Start with single process** and add workers when needed +2. **Use one worker per CPU core** as a starting point +3. **Monitor worker memory usage** to prevent leaks +4. **Use broker for inter-process communication** +5. **Handle graceful shutdown** properly +6. **Test with single process** for easier debugging +7. **Configure workers via environment** for flexibility +8. **Monitor worker health** in production + +## Common Patterns + +### Background Task Processing + +```typescript +class BackgroundTaskProcessor { + constructor(private bus: BrokerBus) {} + + async setupTaskProcessing() { + if (cluster.isMaster) { + // Master schedules tasks + setInterval(async () => { + await this.bus.publish('scheduled.task', { + type: 'cleanup', + timestamp: new Date() + }); + }, 60000); + } else { + // Workers process tasks + const subscription = await this.bus.subscribe('scheduled.task'); + subscription.subscribe(async (task) => { + await this.processTask(task); + }); + } + } +} +``` + +### Session Management + +```typescript +class SessionManager { + constructor(private cache: BrokerCache) {} + + async createSession(userId: string): Promise { + const sessionId = this.generateSessionId(); + await this.cache.set(`session:${sessionId}`, { userId }, 3600); + return sessionId; + } + + async getSession(sessionId: string) { + return await this.cache.get(`session:${sessionId}`); + } +} +``` + +## Troubleshooting + +### Workers Not Starting +- Check worker count configuration +- Verify sufficient system resources +- Check for port binding conflicts + +### Inter-Process Communication Issues +- Verify broker configuration +- Check broker connectivity +- Monitor broker performance + +### Memory Issues +- Monitor worker memory usage +- Check for memory leaks +- Configure appropriate limits + +## Next Steps + +- [Broker](./broker.md) - Inter-process communication +- [Configuration](./configuration.md) - Worker configuration options +- [Performance](../performance.md) - Performance optimization +- [Deployment](./deployment.md) - Production deployment with workers diff --git a/website/src/pages/documentation/framework/zones.md b/website/src/pages/documentation/framework/zones.md new file mode 100644 index 000000000..88242a2d3 --- /dev/null +++ b/website/src/pages/documentation/framework/zones.md @@ -0,0 +1,431 @@ +# Zones and Request Context + +Zones in Deepkit Framework provide a way to maintain request context across asynchronous operations. They enable you to track and access request-specific data throughout the entire request lifecycle. + +## What are Zones? + +Zones are execution contexts that persist across asynchronous boundaries. They allow you to: + +- Track request-specific data +- Maintain context across async operations +- Implement request tracing and logging +- Manage request-scoped resources + +## Enabling Zones + +Enable zones in your application: + +```typescript +import { Zone } from '@deepkit/framework'; + +// Enable zones globally +Zone.enable(); + +// Now zones are active for all async operations +``` + +## Basic Zone Usage + +### Creating and Running Zones + +```typescript +import { Zone } from '@deepkit/framework'; + +// Run code in a zone with context +await Zone.run({ requestId: '123', userId: 'user456' }, async () => { + // This code runs in the zone context + console.log('Current zone:', Zone.current()); + + await someAsyncOperation(); + + // Zone context is preserved across async operations + console.log('Still in zone:', Zone.current()); +}); +``` + +### Accessing Zone Context + +```typescript +class UserService { + async getUser(id: number) { + const zone = Zone.current(); + const requestId = zone?.requestId; + + console.log(`Processing request ${requestId} for user ${id}`); + + return await this.database.findUser(id); + } +} +``` + +## HTTP Request Zones + +The framework automatically creates zones for HTTP requests: + +```typescript +import { http } from '@deepkit/http'; + +class UserController { + @http.GET('/users/:id') + async getUser(@http.param() id: number) { + // Zone is automatically created for this request + const zone = Zone.current(); + + // Zone contains request-specific information + console.log('Request zone:', zone); + + // Call other services - zone context is preserved + return await this.userService.getUser(id); + } +} +``` + +## RPC Request Zones + +Zones are also created for RPC requests: + +```typescript +import { rpc } from '@deepkit/rpc'; + +@rpc.controller('users') +class UserRpcController { + @rpc.action() + async getUser(id: number) { + // Zone is automatically created for RPC calls + const zone = Zone.current(); + + console.log('RPC zone:', zone); + + return await this.userService.getUser(id); + } +} +``` + +## Custom Zone Context + +Add custom data to zones: + +```typescript +class RequestTrackingService { + async processRequest(requestData: any) { + await Zone.run({ + requestId: this.generateRequestId(), + startTime: Date.now(), + userId: requestData.userId, + operation: 'processRequest' + }, async () => { + await this.validateRequest(requestData); + await this.processData(requestData); + await this.logCompletion(); + }); + } + + private async validateRequest(data: any) { + const zone = Zone.current(); + console.log(`Validating request ${zone?.requestId}`); + + // Validation logic + } + + private async processData(data: any) { + const zone = Zone.current(); + console.log(`Processing data for request ${zone?.requestId}`); + + // Processing logic + } + + private async logCompletion() { + const zone = Zone.current(); + const duration = Date.now() - (zone?.startTime || 0); + + console.log(`Request ${zone?.requestId} completed in ${duration}ms`); + } +} +``` + +## Zone-Aware Logging + +Use zones for contextual logging: + +```typescript +import { Logger } from '@deepkit/logger'; + +class ZoneAwareLogger { + constructor(private logger: Logger) {} + + log(message: string, ...args: any[]) { + const zone = Zone.current(); + const requestId = zone?.requestId || 'no-request'; + + this.logger.log(`[${requestId}] ${message}`, ...args); + } + + error(message: string, error?: Error) { + const zone = Zone.current(); + const requestId = zone?.requestId || 'no-request'; + + this.logger.error(`[${requestId}] ${message}`, error); + } +} + +// Usage in services +class UserService { + constructor(private logger: ZoneAwareLogger) {} + + async createUser(userData: any) { + this.logger.log('Creating user'); + + try { + const user = await this.database.save(userData); + this.logger.log('User created successfully', { userId: user.id }); + return user; + } catch (error) { + this.logger.error('Failed to create user', error); + throw error; + } + } +} +``` + +## Request Tracing + +Implement distributed tracing using zones: + +```typescript +class TracingService { + async startTrace(operation: string) { + const traceId = this.generateTraceId(); + const spanId = this.generateSpanId(); + + await Zone.run({ + traceId, + spanId, + operation, + startTime: Date.now() + }, async () => { + await this.executeOperation(operation); + }); + } + + async createSpan(operation: string) { + const parentZone = Zone.current(); + const spanId = this.generateSpanId(); + + await Zone.run({ + ...parentZone, + spanId, + parentSpanId: parentZone?.spanId, + operation, + startTime: Date.now() + }, async () => { + await this.executeSubOperation(operation); + }); + } + + private generateTraceId(): string { + return Math.random().toString(36).substring(2); + } + + private generateSpanId(): string { + return Math.random().toString(36).substring(2); + } +} +``` + +## Error Tracking with Zones + +Track errors with request context: + +```typescript +class ErrorTracker { + async trackError(error: Error) { + const zone = Zone.current(); + + const errorReport = { + message: error.message, + stack: error.stack, + requestId: zone?.requestId, + userId: zone?.userId, + operation: zone?.operation, + timestamp: new Date() + }; + + await this.sendErrorReport(errorReport); + } + + private async sendErrorReport(report: any) { + // Send to error tracking service + console.log('Error report:', report); + } +} + +// Global error handler +process.on('unhandledRejection', async (error: Error) => { + const errorTracker = new ErrorTracker(); + await errorTracker.trackError(error); +}); +``` + +## Zone Middleware + +Create middleware that uses zones: + +```typescript +import { HttpMiddleware, HttpRequest, HttpResponse } from '@deepkit/http'; + +class ZoneMiddleware implements HttpMiddleware { + async execute(request: HttpRequest, response: HttpResponse, next: () => Promise) { + const requestId = this.generateRequestId(); + + await Zone.run({ + requestId, + method: request.method, + url: request.url, + startTime: Date.now(), + userAgent: request.headers['user-agent'] + }, async () => { + try { + await next(); + } finally { + const zone = Zone.current(); + const duration = Date.now() - (zone?.startTime || 0); + console.log(`Request ${requestId} completed in ${duration}ms`); + } + }); + } + + private generateRequestId(): string { + return Math.random().toString(36).substring(2); + } +} +``` + +## Testing with Zones + +Test zone functionality: + +```typescript +import { Zone } from '@deepkit/framework'; + +test('zone context preservation', async () => { + Zone.enable(); + + const testData = { testId: '123', value: 'test' }; + + await Zone.run(testData, async () => { + // Zone context should be available + expect(Zone.current()).toEqual(testData); + + // Test async operation + await new Promise(resolve => setTimeout(resolve, 10)); + + // Zone context should still be available + expect(Zone.current()).toEqual(testData); + + // Test nested async operation + await someAsyncFunction(); + + // Zone context should still be preserved + expect(Zone.current()).toEqual(testData); + }); +}); + +async function someAsyncFunction() { + const zone = Zone.current(); + expect(zone?.testId).toBe('123'); + + await new Promise(resolve => setTimeout(resolve, 5)); + + const zoneAfter = Zone.current(); + expect(zoneAfter?.testId).toBe('123'); +} +``` + +## Performance Considerations + +### Zone Overhead + +Zones add minimal overhead but consider: + +```typescript +// Enable zones only when needed +if (process.env.NODE_ENV !== 'production') { + Zone.enable(); +} + +// Or enable selectively +if (process.env.ENABLE_TRACING === 'true') { + Zone.enable(); +} +``` + +### Memory Management + +Avoid storing large objects in zones: + +```typescript +// Good: Store minimal context +await Zone.run({ + requestId: '123', + userId: 'user456' +}, async () => { + // Process request +}); + +// Avoid: Storing large objects +await Zone.run({ + requestId: '123', + largeObject: hugeDataStructure // This can cause memory issues +}, async () => { + // Process request +}); +``` + +## Best Practices + +1. **Enable zones early** in your application lifecycle +2. **Store minimal data** in zone context +3. **Use zones for request tracking** and logging +4. **Implement error tracking** with zone context +5. **Test zone behavior** thoroughly +6. **Consider performance impact** in high-load scenarios +7. **Use zones for debugging** and monitoring +8. **Document zone context structure** for your team + +## Common Use Cases + +1. **Request ID tracking** across services +2. **User context** preservation +3. **Distributed tracing** implementation +4. **Contextual logging** and monitoring +5. **Error tracking** with request context +6. **Performance monitoring** per request +7. **Security context** management +8. **Multi-tenant** context isolation + +## Troubleshooting + +### Zone Context Lost + +If zone context is lost: + +1. Ensure zones are enabled before use +2. Check for non-zone-aware async operations +3. Verify proper zone creation +4. Test with simpler async patterns + +### Memory Issues + +If experiencing memory problems: + +1. Reduce zone context size +2. Clean up zone data when possible +3. Monitor memory usage +4. Consider disabling zones in production + +## Next Steps + +- [Application Server](./application-server.md) - Server request handling +- [Testing](./testing.md) - Testing zone functionality +- [Performance](../performance.md) - Zone performance optimization +- [Monitoring](../monitoring.md) - Request monitoring and tracing From 7bb39b25c6273a5604adb1f815918c03fb9296d3 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 20:28:37 +0200 Subject: [PATCH 06/15] docs: add event --- website/src/pages/documentation/event.md | 387 +++++++++++++++++++ website/src/pages/documentation/framework.md | 89 +---- 2 files changed, 403 insertions(+), 73 deletions(-) create mode 100644 website/src/pages/documentation/event.md diff --git a/website/src/pages/documentation/event.md b/website/src/pages/documentation/event.md new file mode 100644 index 000000000..85f6266a4 --- /dev/null +++ b/website/src/pages/documentation/event.md @@ -0,0 +1,387 @@ +# Event System + +The Deepkit Event package provides a powerful, type-safe event system for building event-driven applications. It supports both synchronous and asynchronous event handling with full TypeScript type safety and dependency injection integration. + +## Installation + +```bash +npm install @deepkit/event +``` + +## Quick Start + +```typescript +import { EventDispatcher, EventToken, DataEventToken } from '@deepkit/event'; + +// Create an event dispatcher +const dispatcher = new EventDispatcher(); + +// Define an event token +const UserCreated = new DataEventToken<{id: string, name: string}>('user.created'); + +// Listen to events +dispatcher.listen(UserCreated, (event) => { + console.log('User created:', event.data.name); +}); + +// Dispatch events +await dispatcher.dispatch(UserCreated, {id: '1', name: 'John'}); +``` + +## Core Concepts + +### Event Tokens + +Event tokens are unique identifiers that define both the event type and its data structure. They serve as the contract between event dispatchers and listeners. + +#### EventToken (Asynchronous) + +For events that can be handled asynchronously: + +```typescript +import { EventToken, BaseEvent } from '@deepkit/event'; + +// Simple event without data +const AppStarted = new EventToken('app.started'); + +// Event with custom data +class UserEvent extends BaseEvent { + constructor(public userId: string, public action: string) { + super(); + } +} + +const UserAction = new EventToken('user.action'); +``` + +#### EventTokenSync (Synchronous) + +For events that must be handled synchronously: + +```typescript +import { EventTokenSync, DataEvent } from '@deepkit/event'; + +const ConfigChanged = new EventTokenSync>('config.changed'); + +// Synchronous dispatch - returns immediately +dispatcher.dispatch(ConfigChanged, {key: 'theme', value: 'dark'}); +``` + +#### DataEventToken + +A convenient token for events that carry simple data: + +```typescript +import { DataEventToken } from '@deepkit/event'; + +interface User { + id: string; + email: string; +} + +const UserRegistered = new DataEventToken('user.registered'); + +dispatcher.listen(UserRegistered, (event) => { + // event.data is of type User + console.log('New user:', event.data.email); +}); +``` + +### Event Classes + +#### BaseEvent + +The base class for all events, providing control methods: + +```typescript +import { BaseEvent } from '@deepkit/event'; + +class CustomEvent extends BaseEvent { + constructor(public message: string) { + super(); + } +} + +dispatcher.listen(MyEvent, (event) => { + if (event.message === 'stop') { + event.preventDefault(); // Mark event as prevented + event.stopImmediatePropagation(); // Stop further listeners + } +}); +``` + +#### DataEvent + +A generic event class that wraps data: + +```typescript +import { DataEvent } from '@deepkit/event'; + +const event = new DataEvent({userId: '123', action: 'login'}); +console.log(event.data.userId); // '123' +``` + +## Event Dispatcher + +The `EventDispatcher` is the central hub for managing events: + +```typescript +import { EventDispatcher } from '@deepkit/event'; +import { InjectorContext } from '@deepkit/injector'; + +// Create with optional injector context for dependency injection +const injector = InjectorContext.forProviders([]); +const dispatcher = new EventDispatcher(injector); +``` + +### Listening to Events + +#### Functional Listeners + +```typescript +// Basic listener +const unsubscribe = dispatcher.listen(UserCreated, (event) => { + console.log('User created:', event.data); +}); + +// Listener with order (lower numbers execute first) +dispatcher.listen(UserCreated, (event) => { + console.log('This runs first'); +}, -10); + +dispatcher.listen(UserCreated, (event) => { + console.log('This runs second'); +}, 0); + +// Unsubscribe +unsubscribe(); +``` + +#### Class-based Listeners + +```typescript +import { eventDispatcher } from '@deepkit/event'; + +class UserService { + @eventDispatcher.listen(UserCreated) + onUserCreated(event: typeof UserCreated.event) { + console.log('User created:', event.data); + } + + @eventDispatcher.listen(UserCreated, -5) // Custom order + onUserCreatedFirst(event: typeof UserCreated.event) { + console.log('This runs before onUserCreated'); + } +} +``` + +### Dispatching Events + +#### Asynchronous Dispatch + +```typescript +// Simple event +await dispatcher.dispatch(AppStarted); + +// Event with data +await dispatcher.dispatch(UserCreated, {id: '1', name: 'John'}); + +// Event with custom event object +await dispatcher.dispatch(UserAction, new UserEvent('123', 'login')); +``` + +#### Synchronous Dispatch + +```typescript +// Synchronous events return immediately +dispatcher.dispatch(ConfigChanged, {key: 'theme', value: 'dark'}); +``` + +#### Event Factories + +For performance optimization, you can use event factories that only create the event if there are listeners: + +```typescript +// Event factory - only called if there are listeners +await dispatcher.dispatch(UserCreated, () => { + console.log('Creating expensive event data...'); + return {id: generateId(), name: 'John'}; +}); +``` + +## Advanced Features + +### Event Ordering + +Control the execution order of listeners using the order parameter: + +```typescript +dispatcher.listen(UserCreated, () => console.log('Third'), 10); +dispatcher.listen(UserCreated, () => console.log('First'), -10); +dispatcher.listen(UserCreated, () => console.log('Second'), 0); +``` + +### Event Propagation Control + +```typescript +dispatcher.listen(UserCreated, (event) => { + if (someCondition) { + event.preventDefault(); // Mark as prevented + event.stopImmediatePropagation(); // Stop other listeners + } +}); + +dispatcher.listen(UserCreated, (event) => { + if (event.defaultPrevented) { + console.log('Event was prevented'); + } +}); +``` + +### Waiting for Events + +Wait for the next occurrence of an event: + +```typescript +// Wait for the next user creation +const event = await dispatcher.next(UserCreated); +console.log('Next user:', event.data.name); +``` + +### Checking for Listeners + +```typescript +if (dispatcher.hasListeners(UserCreated)) { + console.log('Someone is listening to user creation events'); +} +``` + +### Performance Optimization + +Get a pre-compiled dispatcher for maximum performance: + +```typescript +const userCreatedDispatcher = dispatcher.getDispatcher(UserCreated); + +// This is faster than dispatcher.dispatch() for repeated calls +await userCreatedDispatcher({id: '1', name: 'John'}); +``` + +## Testing Events + +### Event Watcher + +Use the event watcher utility for testing: + +```typescript +import { eventWatcher } from '@deepkit/event'; + +const watcher = eventWatcher(dispatcher, [UserCreated, UserDeleted]); + +// Trigger some events +await dispatcher.dispatch(UserCreated, {id: '1', name: 'John'}); +await dispatcher.dispatch(UserDeleted, {id: '1'}); + +// Check dispatched events +const createdEvent = watcher.get(UserCreated); +expect(createdEvent.name).toBe('John'); + +// Check all messages +expect(watcher.messages).toEqual(['user.created', 'user.deleted']); + +// Clear for next test +watcher.clear(); +``` + +### Testing with Framework + +When using with Deepkit Framework: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +class UserEventListener { + lastCreatedUser?: string; + + @eventDispatcher.listen(UserCreated) + onUserCreated(event: typeof UserCreated.event) { + this.lastCreatedUser = event.data.name; + } +} + +test('event listener', async () => { + const testing = createTestingApp({ + listeners: [UserEventListener] + }); + + await testing.app.dispatch(UserCreated, {id: '1', name: 'John'}); + + const listener = testing.app.get(UserEventListener); + expect(listener.lastCreatedUser).toBe('John'); +}); +``` + +## Integration with Deepkit Framework + +When using with Deepkit Framework, the event system is automatically configured: + +```typescript +import { App } from '@deepkit/app'; +import { EventDispatcher } from '@deepkit/event'; + +const app = new App({ + listeners: [UserService] // Register class-based listeners +}); + +// Use functional listeners +app.listen(UserCreated, (event) => { + console.log('User created:', event.data); +}); + +// Access dispatcher in commands/controllers +app.command('create-user', async (name: string, dispatcher: EventDispatcher) => { + // Create user logic... + await dispatcher.dispatch(UserCreated, {id: '1', name}); +}); +``` + +## Best Practices + +1. **Use descriptive event names**: `user.created`, `order.shipped`, `payment.failed` +2. **Prefer DataEventToken for simple data**: More convenient than custom event classes +3. **Use synchronous events sparingly**: Only when you need immediate execution +4. **Order listeners appropriately**: Use negative numbers for high-priority listeners +5. **Handle errors in listeners**: Uncaught errors can break the event chain +6. **Use event factories for expensive operations**: Only create data when needed +7. **Test event-driven code thoroughly**: Use event watchers and mocking + +## API Reference + +### EventToken +- `constructor(id: string, event?: ClassType)` +- `listen(callback, order?, module?): EventListener` + +### EventTokenSync +- Extends `EventToken` +- `sync: boolean = true` + +### DataEventToken +- Extends `EventToken>` + +### EventDispatcher +- `listen(eventToken: T, callback, order?): EventDispatcherUnsubscribe` +- `dispatch(eventToken: T, ...args): Promise | void` +- `next(eventToken: T): Promise` +- `hasListeners(eventToken): boolean` +- `getDispatcher(eventToken: T): Dispatcher` + +### BaseEvent +- `preventDefault(): void` +- `stopImmediatePropagation(): void` +- `defaultPrevented: boolean` +- `immediatePropagationStopped: boolean` + +### DataEvent +- Extends `BaseEvent` +- `constructor(data: T)` +- `data: T` diff --git a/website/src/pages/documentation/framework.md b/website/src/pages/documentation/framework.md index 3e084b923..ef4ea56af 100644 --- a/website/src/pages/documentation/framework.md +++ b/website/src/pages/documentation/framework.md @@ -217,90 +217,33 @@ Modules config ... ``` -## Documentation +## Application Server -### Getting Started -- [Getting Started](./framework/getting-started.md) - Quick start guide and basic concepts -- [Configuration](./framework/configuration.md) - Complete configuration reference -- [Application Server](./framework/application-server.md) - Server lifecycle and management +## File Structure -### Core Features -- [RPC](./framework/rpc.md) - Real-time communication with WebSockets -- [Database](./framework/database.md) - Database integration and migrations -- [Testing](./framework/testing.md) - Comprehensive testing strategies -- [Events](./framework/events.md) - Event system and lifecycle hooks - -### Advanced Topics -- [Workers](./framework/workers.md) - Multi-process architecture -- [Broker](./framework/broker.md) - Message broker and inter-process communication -- [Debugging & Profiling](./framework/debugging-profiling.md) - Debug tools and performance analysis -- [Zones](./framework/zones.md) - Request context management -- [Filesystem](./framework/filesystem.md) - File storage abstraction - -### Deployment & Production -- [Deployment](./framework/deployment.md) - Production deployment strategies -- [Public Directory](./framework/public.md) - Static file serving -- [API Console](./framework/api-console.md) - Interactive API documentation - -### Related Documentation -- [HTTP](./http.md) - HTTP controllers and REST APIs -- [Dependency Injection](./dependency-injection.md) - Service container and providers -- [ORM](./orm.md) - Database modeling and queries -- [App](./app.md) - Application foundation and CLI - -## Quick Examples - -### HTTP API -```typescript -import { http } from '@deepkit/http'; +## Auto-CRUD -class UserController { - @http.GET('/users/:id') - getUser(@http.param() id: number) { - return { id, name: `User ${id}` }; - } +## Events - @http.POST('/users') - createUser(@http.body() userData: CreateUserData) { - return this.userService.create(userData); - } -} -``` +Deepkit framework comes with various event tokens on which event listeners can be registered. -### RPC Controller -```typescript -import { rpc } from '@deepkit/rpc'; +See the [Events](./app/events.md) chapter to learn more about how events work. -@rpc.controller('users') -class UserRpcController { - @rpc.action() - async getUser(id: number): Promise { - return await this.userService.findById(id); - } +### Dispatch Events - @rpc.action() - getUserUpdates(): Observable { - return this.userService.getUpdateStream(); - } -} -``` +Events are sent via the `EventDispatcher` class. In a Deepkit Framework application, this can be provided via dependency injection. -### Service with Dependencies ```typescript -class UserService { - constructor( - private database: Database, - private logger: Logger, - private eventDispatcher: EventDispatcher - ) {} - - async createUser(userData: CreateUserData): Promise { - const user = await this.database.persist(new User(userData)); +import { cli, Command } from '@deepkit/app'; +import { EventDispatcher } from '@deepkit/event'; - await this.eventDispatcher.dispatch(onUserCreated, new UserCreatedEvent(user.id)); - this.logger.log(`User created: ${user.id}`); +@cli.controller('test') +export class TestCommand implements Command { + constructor(protected eventDispatcher: EventDispatcher) { + } - return user; + async execute() { + this.eventDispatcher.dispatch(UserAdded, new UserEvent({ username: 'Peter' })); } } ``` From 8ce206d8dc7cef8a6a5efa0a655a0b9271b9cd42 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 20:41:55 +0200 Subject: [PATCH 07/15] docs: add workflow --- website/src/pages/documentation/workflow.md | 323 +++++++++ .../workflow/advanced-features.md | 685 ++++++++++++++++++ .../workflow/dependency-injection.md | 596 +++++++++++++++ .../pages/documentation/workflow/events.md | 469 ++++++++++++ .../documentation/workflow/getting-started.md | 379 ++++++++++ .../workflow/state-management.md | 576 +++++++++++++++ .../pages/documentation/workflow/testing.md | 564 ++++++++++++++ 7 files changed, 3592 insertions(+) create mode 100644 website/src/pages/documentation/workflow.md create mode 100644 website/src/pages/documentation/workflow/advanced-features.md create mode 100644 website/src/pages/documentation/workflow/dependency-injection.md create mode 100644 website/src/pages/documentation/workflow/events.md create mode 100644 website/src/pages/documentation/workflow/getting-started.md create mode 100644 website/src/pages/documentation/workflow/state-management.md create mode 100644 website/src/pages/documentation/workflow/testing.md diff --git a/website/src/pages/documentation/workflow.md b/website/src/pages/documentation/workflow.md new file mode 100644 index 000000000..077649eed --- /dev/null +++ b/website/src/pages/documentation/workflow.md @@ -0,0 +1,323 @@ +# Workflow Engine + +The Deepkit Workflow package provides a powerful, type-safe finite state machine and workflow engine for building complex business processes. It integrates seamlessly with Deepkit's event system and dependency injection, offering high performance through compiled state transitions. + +## Installation + +```bash +npm install @deepkit/workflow +``` + +## Quick Start + +```typescript +import { createWorkflow, WorkflowEvent } from '@deepkit/workflow'; +import { EventDispatcher } from '@deepkit/event'; + +// Define custom events for specific states +class OrderProcessedEvent extends WorkflowEvent { + constructor(public orderId: string, public amount: number) { + super(); + } +} + +// Create a workflow definition +const orderWorkflow = createWorkflow('orderProcessing', { + created: WorkflowEvent, + processing: WorkflowEvent, + paid: OrderProcessedEvent, + shipped: WorkflowEvent, + delivered: WorkflowEvent, + cancelled: WorkflowEvent, +}, { + // Define allowed transitions + created: ['processing', 'cancelled'], + processing: ['paid', 'cancelled'], + paid: ['shipped', 'cancelled'], + shipped: ['delivered'], + // delivered and cancelled are final states (no transitions) +}); + +// Create a workflow instance +const dispatcher = new EventDispatcher(); +const workflow = orderWorkflow.create('created', dispatcher); + +// Check current state +console.log(workflow.state.get()); // 'created' +console.log(workflow.isDone()); // false + +// Check possible transitions +console.log(workflow.can('processing')); // true +console.log(workflow.can('paid')); // false + +// Apply state transitions +await workflow.apply('processing'); +console.log(workflow.state.get()); // 'processing' + +await workflow.apply('paid', new OrderProcessedEvent('order-123', 99.99)); +console.log(workflow.state.get()); // 'paid' +``` + +## Core Concepts + +### Workflow Definition + +A workflow is defined using the `createWorkflow()` function with three parameters: + +1. **Name**: A unique identifier for the workflow +2. **Places**: An object mapping state names to event classes +3. **Transitions**: An object defining allowed state transitions + +```typescript +const workflow = createWorkflow('myWorkflow', { + // Places (states) with their associated event types + start: WorkflowEvent, + processing: WorkflowEvent, + completed: CustomCompletedEvent, + failed: WorkflowEvent, +}, { + // Transitions (from -> to) + start: 'processing', + processing: ['completed', 'failed'], // Multiple possible transitions + // completed and failed are final states +}); +``` + +### Workflow Events + +All workflow states are associated with event classes that extend `WorkflowEvent`: + +```typescript +import { WorkflowEvent } from '@deepkit/workflow'; + +class CustomEvent extends WorkflowEvent { + constructor( + public data: string, + public timestamp: Date = new Date() + ) { + super(); + } +} +``` + +### State Transitions + +State transitions are validated at runtime. You can only transition to states that are explicitly allowed: + +```typescript +// This will succeed if 'processing' is allowed from current state +await workflow.apply('processing'); + +// This will throw WorkflowError if transition is not allowed +await workflow.apply('completed'); // Error if not allowed from current state +``` + +### Event Tokens + +Each state in the workflow automatically gets an event token that you can listen to: + +```typescript +// Listen to state transitions +dispatcher.listen(orderWorkflow.onPaid, (event) => { + console.log('Order paid:', event.orderId, event.amount); +}); + +dispatcher.listen(orderWorkflow.onShipped, (event) => { + console.log('Order shipped'); +}); +``` + +## Event-Driven Architecture + +The workflow engine integrates with Deepkit's event system, allowing you to build reactive applications: + +### Functional Listeners + +```typescript +import { EventDispatcher } from '@deepkit/event'; + +const dispatcher = new EventDispatcher(); +const workflow = orderWorkflow.create('created', dispatcher); + +// Listen to workflow events +dispatcher.listen(orderWorkflow.onProcessing, async (event) => { + console.log('Order is being processed'); + // Perform processing logic +}); + +dispatcher.listen(orderWorkflow.onPaid, async (event) => { + console.log('Payment received for order:', event.orderId); + // Send confirmation email + // Update inventory +}); +``` + +### Class-based Listeners + +```typescript +import { eventDispatcher } from '@deepkit/event'; + +class OrderService { + @eventDispatcher.listen(orderWorkflow.onPaid) + async onOrderPaid(event: typeof orderWorkflow.onPaid.event) { + console.log('Processing payment for order:', event.orderId); + // Business logic here + } + + @eventDispatcher.listen(orderWorkflow.onShipped) + async onOrderShipped(event: typeof orderWorkflow.onShipped.event) { + console.log('Order shipped, sending notification'); + // Send shipping notification + } +} +``` + +## Advanced Features + +### Automatic State Progression + +Events can trigger automatic progression to the next state using the `next()` method: + +```typescript +dispatcher.listen(orderWorkflow.onProcessing, async (event) => { + // Perform processing logic + const paymentResult = await processPayment(); + + if (paymentResult.success) { + // Automatically transition to 'paid' state + event.next('paid', new OrderProcessedEvent( + paymentResult.orderId, + paymentResult.amount + )); + } else { + event.next('cancelled'); + } +}); + +// Apply the initial transition - will automatically progress through states +await workflow.apply('processing'); +console.log(workflow.state.get()); // Could be 'paid' or 'cancelled' +``` + +### Dependency Injection + +Workflow event listeners support full dependency injection: + +```typescript +import { InjectorContext, InjectorModule } from '@deepkit/injector'; + +class PaymentService { + async processPayment(orderId: string): Promise { + // Payment processing logic + return true; + } +} + +class OrderListener { + constructor(private paymentService: PaymentService) {} + + @eventDispatcher.listen(orderWorkflow.onProcessing) + async onProcessing(event: typeof orderWorkflow.onProcessing.event) { + const success = await this.paymentService.processPayment('order-123'); + if (success) { + event.next('paid', new OrderProcessedEvent('order-123', 99.99)); + } + } +} + +// Setup with dependency injection +const module = new InjectorModule([PaymentService, OrderListener]); +const injector = new InjectorContext(module); +const dispatcher = new EventDispatcher(injector); + +// Register the listener +dispatcher.registerListener(OrderListener, module); + +const workflow = orderWorkflow.create('created', dispatcher); +``` + +### Performance Optimization + +The workflow engine compiles state transition logic for maximum performance: + +```typescript +// The first call compiles the transition logic +await workflow.apply('processing'); // Compilation happens here + +// Subsequent calls use the compiled version (much faster) +await workflow.apply('paid', new OrderProcessedEvent('order-456', 149.99)); +``` + +### State Validation + +The workflow engine validates transitions and event types: + +```typescript +// This will throw an error if wrong event type is provided +await workflow.apply('paid', new WorkflowEvent()); +// Error: State paid got the wrong event. Expected OrderProcessedEvent, got WorkflowEvent + +// This will throw an error if transition is not allowed +await workflow.apply('delivered'); +// Error: Can not apply state change from created->delivered +``` + +## Integration with Deepkit Framework + +When using with Deepkit Framework, workflows integrate seamlessly: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; + +const app = new App({ + listeners: [OrderService], // Register workflow listeners + imports: [new FrameworkModule()] +}); + +// Use in controllers or commands +app.command('process-order', async (orderId: string, dispatcher: EventDispatcher) => { + const workflow = orderWorkflow.create('created', dispatcher); + await workflow.apply('processing'); +}); +``` + +## Best Practices + +1. **Use descriptive state names**: `orderCreated`, `paymentProcessing`, `orderShipped` +2. **Create custom event classes**: Include relevant data for each state transition +3. **Validate business rules in listeners**: Use event listeners to enforce business logic +4. **Handle errors gracefully**: Implement error states and recovery mechanisms +5. **Use dependency injection**: Leverage DI for testable, maintainable code +6. **Test workflows thoroughly**: Test all possible state transitions and edge cases + +## API Reference + +### createWorkflow(name, places, transitions) +- **name**: `string` - Unique workflow identifier +- **places**: `T` - Object mapping state names to event classes +- **transitions**: `WorkflowTransitions` - Object defining allowed transitions +- **Returns**: `WorkflowDefinition & WorkflowDefinitionEvents` + +### WorkflowDefinition +- `create(state, eventDispatcher, injector?, stopwatch?)`: Create workflow instance +- `getEventToken(name)`: Get event token for a state +- `getTransitionsFrom(state)`: Get allowed transitions from a state + +### Workflow +- `state`: Current workflow state +- `can(nextState)`: Check if transition is allowed +- `apply(nextState, event?)`: Apply state transition +- `isDone()`: Check if workflow is in a final state + +### WorkflowEvent +- `next(nextState, event?)`: Schedule next state transition +- `hasNext()`: Check if next state is scheduled +- `clearNext()`: Clear scheduled next state + +## Next Steps + +- [Getting Started](./workflow/getting-started.md) - Detailed tutorial and examples +- [Events](./workflow/events.md) - Advanced event handling patterns +- [State Management](./workflow/state-management.md) - Complex state transition patterns +- [Testing](./workflow/testing.md) - Testing strategies for workflows diff --git a/website/src/pages/documentation/workflow/advanced-features.md b/website/src/pages/documentation/workflow/advanced-features.md new file mode 100644 index 000000000..468ff20db --- /dev/null +++ b/website/src/pages/documentation/workflow/advanced-features.md @@ -0,0 +1,685 @@ +# Advanced Features + +This guide covers advanced workflow features including performance optimization, debugging, monitoring, and integration patterns for production applications. + +## Performance Optimization + +### Compiled State Transitions + +Deepkit's workflow engine compiles state transition logic for maximum performance: + +```typescript +import { createWorkflow, WorkflowEvent } from '@deepkit/workflow'; +import { EventDispatcher } from '@deepkit/event'; +import { Stopwatch } from '@deepkit/stopwatch'; + +const performantWorkflow = createWorkflow('performant', { + start: WorkflowEvent, + processing: WorkflowEvent, + completed: WorkflowEvent, +}, { + start: 'processing', + processing: 'completed', +}); + +// Performance measurement +async function measureWorkflowPerformance() { + const dispatcher = new EventDispatcher(); + const stopwatch = new Stopwatch(); + + const workflow = performantWorkflow.create('start', dispatcher, undefined, stopwatch); + + // First call compiles the transition logic + stopwatch.start('first-transition'); + await workflow.apply('processing'); + const firstTime = stopwatch.end('first-transition'); + + // Subsequent calls use compiled version (much faster) + const workflow2 = performantWorkflow.create('start', dispatcher, undefined, stopwatch); + stopwatch.start('second-transition'); + await workflow2.apply('processing'); + const secondTime = stopwatch.end('second-transition'); + + console.log(`First transition: ${firstTime}ms`); + console.log(`Second transition: ${secondTime}ms`); + console.log(`Performance improvement: ${((firstTime - secondTime) / firstTime * 100).toFixed(1)}%`); +} +``` + +### Batch Processing + +Process multiple workflows efficiently: + +```typescript +class WorkflowBatchProcessor { + private batches = new Map, state: string, event?: any}>>(); + + addToBatch(workflowType: string, workflow: Workflow, state: string, event?: any) { + if (!this.batches.has(workflowType)) { + this.batches.set(workflowType, []); + } + this.batches.get(workflowType)!.push({ workflow, state, event }); + } + + async processBatches(): Promise { + const promises: Promise[] = []; + + for (const [workflowType, batch] of this.batches) { + // Process each batch in parallel + const batchPromise = this.processBatch(batch); + promises.push(batchPromise); + } + + await Promise.all(promises); + this.batches.clear(); + } + + private async processBatch(batch: Array<{workflow: Workflow, state: string, event?: any}>): Promise { + // Process workflows in parallel within the batch + const promises = batch.map(({ workflow, state, event }) => + workflow.apply(state, event) + ); + + await Promise.all(promises); + } +} + +// Usage +const batchProcessor = new WorkflowBatchProcessor(); + +// Add workflows to batch +for (let i = 0; i < 1000; i++) { + const workflow = performantWorkflow.create('start', dispatcher); + batchProcessor.addToBatch('performant', workflow, 'processing'); +} + +// Process all batches efficiently +await batchProcessor.processBatches(); +``` + +### Memory-Efficient State Storage + +Implement memory-efficient state storage for large-scale workflows: + +```typescript +interface CompactWorkflowState { + workflowId: string; + currentState: keyof T & string; + metadata?: Record; +} + +class CompactStateManager { + private states = new Map>(); + private statePool: CompactWorkflowState[] = []; + + createState(workflowId: string, initialState: keyof T & string): CompactWorkflowState { + // Reuse objects from pool to reduce GC pressure + let state = this.statePool.pop(); + if (!state) { + state = { workflowId: '', currentState: initialState, metadata: {} }; + } + + state.workflowId = workflowId; + state.currentState = initialState; + state.metadata = {}; + + this.states.set(workflowId, state); + return state; + } + + updateState(workflowId: string, newState: keyof T & string): void { + const state = this.states.get(workflowId); + if (state) { + state.currentState = newState; + } + } + + removeState(workflowId: string): void { + const state = this.states.get(workflowId); + if (state) { + this.states.delete(workflowId); + // Return to pool for reuse + this.statePool.push(state); + } + } + + getState(workflowId: string): CompactWorkflowState | undefined { + return this.states.get(workflowId); + } + + getStats() { + return { + activeStates: this.states.size, + pooledStates: this.statePool.length, + memoryUsage: process.memoryUsage() + }; + } +} +``` + +## Debugging and Monitoring + +### Workflow Debugging + +Add comprehensive debugging capabilities: + +```typescript +class WorkflowDebugger { + private transitionLog: Array<{ + timestamp: Date; + workflowId: string; + from: keyof T & string; + to: keyof T & string; + event?: any; + duration?: number; + }> = []; + + constructor(private workflow: Workflow, private workflowId: string) { + this.setupDebugListeners(); + } + + private setupDebugListeners() { + // Intercept all state transitions + const originalApply = this.workflow.apply.bind(this.workflow); + + this.workflow.apply = async (nextState: any, event?: any) => { + const startTime = Date.now(); + const fromState = this.workflow.state.get(); + + console.log(`[${this.workflowId}] Transitioning from ${fromState} to ${nextState}`); + + try { + await originalApply(nextState, event); + + const duration = Date.now() - startTime; + this.transitionLog.push({ + timestamp: new Date(), + workflowId: this.workflowId, + from: fromState, + to: nextState, + event, + duration + }); + + console.log(`[${this.workflowId}] Transition completed in ${duration}ms`); + } catch (error) { + console.error(`[${this.workflowId}] Transition failed:`, error); + throw error; + } + }; + } + + getTransitionLog() { + return [...this.transitionLog]; + } + + getTransitionStats() { + const stats = new Map(); + + for (const log of this.transitionLog) { + const key = `${log.from}->${log.to}`; + const existing = stats.get(key) || {count: 0, totalDuration: 0, avgDuration: 0}; + + existing.count++; + existing.totalDuration += log.duration || 0; + existing.avgDuration = existing.totalDuration / existing.count; + + stats.set(key, existing); + } + + return Object.fromEntries(stats); + } + + exportDebugData() { + return { + workflowId: this.workflowId, + currentState: this.workflow.state.get(), + isDone: this.workflow.isDone(), + transitionLog: this.getTransitionLog(), + stats: this.getTransitionStats() + }; + } +} + +// Usage +const workflow = performantWorkflow.create('start', dispatcher); +const debugger = new WorkflowDebugger(workflow, 'debug-workflow-1'); + +await workflow.apply('processing'); +await workflow.apply('completed'); + +console.log('Debug data:', debugger.exportDebugData()); +``` + +### Performance Monitoring + +Monitor workflow performance in production: + +```typescript +class WorkflowMonitor { + private metrics = { + transitionsPerSecond: 0, + averageTransitionTime: 0, + errorRate: 0, + activeWorkflows: 0, + totalTransitions: 0, + totalErrors: 0, + }; + + private transitionTimes: number[] = []; + private lastMetricsUpdate = Date.now(); + + recordTransition(duration: number) { + this.metrics.totalTransitions++; + this.transitionTimes.push(duration); + + // Keep only last 1000 measurements + if (this.transitionTimes.length > 1000) { + this.transitionTimes.shift(); + } + + this.updateMetrics(); + } + + recordError() { + this.metrics.totalErrors++; + this.updateMetrics(); + } + + recordActiveWorkflow(delta: number) { + this.metrics.activeWorkflows += delta; + } + + private updateMetrics() { + const now = Date.now(); + const timeSinceLastUpdate = now - this.lastMetricsUpdate; + + if (timeSinceLastUpdate >= 1000) { // Update every second + this.metrics.transitionsPerSecond = this.metrics.totalTransitions / (timeSinceLastUpdate / 1000); + this.metrics.averageTransitionTime = this.transitionTimes.reduce((a, b) => a + b, 0) / this.transitionTimes.length; + this.metrics.errorRate = this.metrics.totalErrors / this.metrics.totalTransitions; + + this.lastMetricsUpdate = now; + } + } + + getMetrics() { + return { ...this.metrics }; + } + + getHealthStatus() { + const metrics = this.getMetrics(); + + return { + healthy: metrics.errorRate < 0.01 && metrics.averageTransitionTime < 100, + metrics, + alerts: [ + ...(metrics.errorRate > 0.05 ? ['High error rate'] : []), + ...(metrics.averageTransitionTime > 500 ? ['Slow transitions'] : []), + ...(metrics.activeWorkflows > 10000 ? ['High workflow count'] : []), + ] + }; + } +} + +// Global monitor instance +const workflowMonitor = new WorkflowMonitor(); + +// Integrate with workflow events +class MonitoredWorkflowService { + @eventDispatcher.listen(performantWorkflow.onStart) + async onStart(event: any) { + workflowMonitor.recordActiveWorkflow(1); + + const startTime = Date.now(); + try { + // Process the event + await this.processStart(event); + workflowMonitor.recordTransition(Date.now() - startTime); + } catch (error) { + workflowMonitor.recordError(); + throw error; + } + } + + @eventDispatcher.listen(performantWorkflow.onCompleted) + async onCompleted(event: any) { + workflowMonitor.recordActiveWorkflow(-1); + } + + private async processStart(event: any) { + // Business logic + } +} +``` + +## Integration Patterns + +### Workflow Orchestration + +Orchestrate multiple workflows: + +```typescript +class WorkflowOrchestrator { + private workflows = new Map>(); + private dependencies = new Map(); + + registerWorkflow(id: string, workflow: Workflow, dependencies: string[] = []) { + this.workflows.set(id, workflow); + this.dependencies.set(id, dependencies); + } + + async executeWorkflows(): Promise { + const completed = new Set(); + const inProgress = new Set(); + + while (completed.size < this.workflows.size) { + const ready = this.getReadyWorkflows(completed, inProgress); + + if (ready.length === 0) { + throw new Error('Circular dependency or deadlock detected'); + } + + // Execute ready workflows in parallel + const promises = ready.map(async (workflowId) => { + inProgress.add(workflowId); + const workflow = this.workflows.get(workflowId)!; + + try { + await this.executeWorkflow(workflow); + completed.add(workflowId); + } finally { + inProgress.delete(workflowId); + } + }); + + await Promise.all(promises); + } + } + + private getReadyWorkflows(completed: Set, inProgress: Set): string[] { + const ready: string[] = []; + + for (const [workflowId, deps] of this.dependencies) { + if (completed.has(workflowId) || inProgress.has(workflowId)) { + continue; + } + + const allDepsCompleted = deps.every(dep => completed.has(dep)); + if (allDepsCompleted) { + ready.push(workflowId); + } + } + + return ready; + } + + private async executeWorkflow(workflow: Workflow): Promise { + while (!workflow.isDone()) { + const currentState = workflow.state.get(); + const possibleTransitions = workflow.definition.getTransitionsFrom(currentState); + + if (possibleTransitions.length === 0) { + break; // No more transitions possible + } + + // Apply the first possible transition (customize as needed) + await workflow.apply(possibleTransitions[0]); + } + } +} + +// Usage +const orchestrator = new WorkflowOrchestrator(); + +const userWorkflow = createWorkflow('user', { + created: WorkflowEvent, + verified: WorkflowEvent, +}, { created: 'verified' }); + +const orderWorkflow = createWorkflow('order', { + created: WorkflowEvent, + processed: WorkflowEvent, +}, { created: 'processed' }); + +const paymentWorkflow = createWorkflow('payment', { + pending: WorkflowEvent, + completed: WorkflowEvent, +}, { pending: 'completed' }); + +// Register workflows with dependencies +orchestrator.registerWorkflow('user', userWorkflow.create('created', dispatcher)); +orchestrator.registerWorkflow('order', orderWorkflow.create('created', dispatcher), ['user']); +orchestrator.registerWorkflow('payment', paymentWorkflow.create('pending', dispatcher), ['order']); + +// Execute all workflows in dependency order +await orchestrator.executeWorkflows(); +``` + +### Event Sourcing Integration + +Integrate workflows with event sourcing: + +```typescript +interface WorkflowEvent { + id: string; + workflowId: string; + eventType: string; + data: any; + timestamp: Date; + version: number; +} + +class EventSourcingWorkflowStore { + private events: WorkflowEvent[] = []; + private snapshots = new Map(); + + async saveEvent(workflowId: string, eventType: string, data: any): Promise { + const event: WorkflowEvent = { + id: `event_${Date.now()}_${Math.random()}`, + workflowId, + eventType, + data, + timestamp: new Date(), + version: this.getNextVersion(workflowId) + }; + + this.events.push(event); + + // Create snapshot every 10 events + if (event.version % 10 === 0) { + await this.createSnapshot(workflowId, data.newState, event.version); + } + } + + async loadWorkflowState(workflowId: string): Promise<{state: string, version: number}> { + const snapshot = this.snapshots.get(workflowId); + let state = 'created'; + let version = 0; + + if (snapshot) { + state = snapshot.state; + version = snapshot.version; + } + + // Replay events after snapshot + const eventsToReplay = this.events + .filter(e => e.workflowId === workflowId && e.version > version) + .sort((a, b) => a.version - b.version); + + for (const event of eventsToReplay) { + if (event.eventType === 'StateTransition') { + state = event.data.newState; + version = event.version; + } + } + + return { state, version }; + } + + private getNextVersion(workflowId: string): number { + const workflowEvents = this.events.filter(e => e.workflowId === workflowId); + return workflowEvents.length + 1; + } + + private async createSnapshot(workflowId: string, state: string, version: number): Promise { + this.snapshots.set(workflowId, { state, version }); + } + + getEventHistory(workflowId: string): WorkflowEvent[] { + return this.events + .filter(e => e.workflowId === workflowId) + .sort((a, b) => a.version - b.version); + } +} + +// Event-sourced workflow wrapper +class EventSourcedWorkflow { + constructor( + private workflowId: string, + private definition: WorkflowDefinition, + private eventStore: EventSourcingWorkflowStore, + private dispatcher: EventDispatcher + ) {} + + async apply(nextState: keyof T & string, event?: any): Promise { + const currentState = await this.getCurrentState(); + + // Save the transition event + await this.eventStore.saveEvent(this.workflowId, 'StateTransition', { + fromState: currentState, + toState: nextState, + event: event + }); + + // Create temporary workflow for validation and execution + const workflow = new Workflow( + this.definition, + { get: () => currentState, set: () => {} }, + this.dispatcher, + this.dispatcher.injector + ); + + await workflow.apply(nextState, event); + } + + async getCurrentState(): Promise { + const { state } = await this.eventStore.loadWorkflowState(this.workflowId); + return state as keyof T & string; + } + + async getHistory(): Promise { + return this.eventStore.getEventHistory(this.workflowId); + } +} +``` + +## Production Considerations + +### Error Recovery + +Implement robust error recovery: + +```typescript +class WorkflowErrorRecovery { + private retryAttempts = new Map(); + private maxRetries = 3; + private retryDelay = 1000; // 1 second + + async executeWithRetry( + operation: () => Promise, + workflowId: string, + context: string + ): Promise { + const attempts = this.retryAttempts.get(workflowId) || 0; + + try { + const result = await operation(); + this.retryAttempts.delete(workflowId); // Reset on success + return result; + } catch (error) { + if (attempts < this.maxRetries) { + this.retryAttempts.set(workflowId, attempts + 1); + + console.warn(`Workflow ${workflowId} failed in ${context}, retrying (${attempts + 1}/${this.maxRetries})`); + + await this.delay(this.retryDelay * Math.pow(2, attempts)); // Exponential backoff + return this.executeWithRetry(operation, workflowId, context); + } else { + console.error(`Workflow ${workflowId} failed permanently in ${context}:`, error); + this.retryAttempts.delete(workflowId); + throw error; + } + } + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} +``` + +### Health Checks + +Implement workflow health monitoring: + +```typescript +class WorkflowHealthChecker { + async checkHealth(): Promise<{healthy: boolean, details: any}> { + const checks = await Promise.allSettled([ + this.checkEventDispatcher(), + this.checkWorkflowPerformance(), + this.checkMemoryUsage(), + this.checkErrorRates(), + ]); + + const results = checks.map((check, index) => ({ + name: ['eventDispatcher', 'performance', 'memory', 'errors'][index], + status: check.status, + result: check.status === 'fulfilled' ? check.value : check.reason + })); + + const healthy = results.every(r => r.status === 'fulfilled' && r.result.healthy); + + return { healthy, details: results }; + } + + private async checkEventDispatcher(): Promise<{healthy: boolean, details: any}> { + // Check if event dispatcher is responsive + return { healthy: true, details: 'Event dispatcher operational' }; + } + + private async checkWorkflowPerformance(): Promise<{healthy: boolean, details: any}> { + const metrics = workflowMonitor.getMetrics(); + return { + healthy: metrics.averageTransitionTime < 1000, + details: metrics + }; + } + + private async checkMemoryUsage(): Promise<{healthy: boolean, details: any}> { + const usage = process.memoryUsage(); + const heapUsedMB = usage.heapUsed / 1024 / 1024; + + return { + healthy: heapUsedMB < 512, // Less than 512MB + details: { heapUsedMB, ...usage } + }; + } + + private async checkErrorRates(): Promise<{healthy: boolean, details: any}> { + const metrics = workflowMonitor.getMetrics(); + return { + healthy: metrics.errorRate < 0.05, // Less than 5% error rate + details: { errorRate: metrics.errorRate } + }; + } +} +``` + +## Next Steps + +- [Getting Started](./getting-started.md) - Basic workflow concepts +- [Events](./events.md) - Event handling patterns +- [State Management](./state-management.md) - Advanced state patterns +- [Testing](./testing.md) - Comprehensive testing strategies diff --git a/website/src/pages/documentation/workflow/dependency-injection.md b/website/src/pages/documentation/workflow/dependency-injection.md new file mode 100644 index 000000000..51ed68153 --- /dev/null +++ b/website/src/pages/documentation/workflow/dependency-injection.md @@ -0,0 +1,596 @@ +# Dependency Injection with Workflows + +Deepkit's workflow engine integrates seamlessly with the dependency injection system, allowing you to build maintainable, testable workflow applications with proper separation of concerns. + +## Basic DI Integration + +### Setting Up Dependency Injection + +```typescript +import { createWorkflow, WorkflowEvent } from '@deepkit/workflow'; +import { EventDispatcher, eventDispatcher } from '@deepkit/event'; +import { InjectorContext, InjectorModule } from '@deepkit/injector'; + +// Define services +class EmailService { + async sendEmail(to: string, subject: string, body: string): Promise { + console.log(`Sending email to ${to}: ${subject}`); + // Email implementation + } +} + +class PaymentService { + async processPayment(amount: number, customerId: string): Promise<{success: boolean, transactionId?: string}> { + console.log(`Processing payment of $${amount} for customer ${customerId}`); + // Payment processing logic + return { success: true, transactionId: 'txn_123' }; + } +} + +class OrderRepository { + async updateOrderStatus(orderId: string, status: string): Promise { + console.log(`Updating order ${orderId} status to ${status}`); + // Database update logic + } +} + +// Create injector module +const module = new InjectorModule([ + EmailService, + PaymentService, + OrderRepository, +]); + +const injector = new InjectorContext(module); +const dispatcher = new EventDispatcher(injector); +``` + +### Injectable Event Listeners + +Use dependency injection in workflow event listeners: + +```typescript +class OrderCreatedEvent extends WorkflowEvent { + constructor( + public orderId: string, + public customerId: string, + public customerEmail: string, + public amount: number + ) { + super(); + } +} + +class PaymentProcessedEvent extends WorkflowEvent { + constructor( + public orderId: string, + public transactionId: string, + public amount: number + ) { + super(); + } +} + +const orderWorkflow = createWorkflow('order', { + created: OrderCreatedEvent, + paymentPending: WorkflowEvent, + paid: PaymentProcessedEvent, + shipped: WorkflowEvent, + delivered: WorkflowEvent, +}, { + created: 'paymentPending', + paymentPending: 'paid', + paid: 'shipped', + shipped: 'delivered', +}); + +// Injectable listener class +class OrderWorkflowListener { + constructor( + private emailService: EmailService, + private paymentService: PaymentService, + private orderRepository: OrderRepository + ) {} + + @eventDispatcher.listen(orderWorkflow.onCreated) + async onOrderCreated(event: typeof orderWorkflow.onCreated.event) { + // Send order confirmation email + await this.emailService.sendEmail( + event.customerEmail, + 'Order Confirmation', + `Your order ${event.orderId} has been created.` + ); + + // Update order status + await this.orderRepository.updateOrderStatus(event.orderId, 'created'); + + // Automatically proceed to payment + event.next('paymentPending'); + } + + @eventDispatcher.listen(orderWorkflow.onPaymentPending) + async onPaymentPending(event: typeof orderWorkflow.onPaymentPending.event) { + // Process payment + const result = await this.paymentService.processPayment( + event.amount, + event.customerId + ); + + if (result.success) { + event.next('paid', new PaymentProcessedEvent( + event.orderId, + result.transactionId!, + event.amount + )); + } + } + + @eventDispatcher.listen(orderWorkflow.onPaid) + async onPaymentProcessed(event: typeof orderWorkflow.onPaid.event) { + // Send payment confirmation + await this.emailService.sendEmail( + event.customerEmail, + 'Payment Confirmed', + `Payment of $${event.amount} has been processed. Transaction ID: ${event.transactionId}` + ); + + // Update order status + await this.orderRepository.updateOrderStatus(event.orderId, 'paid'); + + // Start shipping process + event.next('shipped'); + } + + @eventDispatcher.listen(orderWorkflow.onShipped) + async onOrderShipped(event: typeof orderWorkflow.onShipped.event) { + // Send shipping notification + await this.emailService.sendEmail( + event.customerEmail, + 'Order Shipped', + `Your order ${event.orderId} has been shipped.` + ); + + // Update order status + await this.orderRepository.updateOrderStatus(event.orderId, 'shipped'); + } +} + +// Register the listener +const orderListener = new OrderWorkflowListener( + injector.get(EmailService), + injector.get(PaymentService), + injector.get(OrderRepository) +); + +// Or register the class for automatic instantiation +dispatcher.registerListener(OrderWorkflowListener, module); +``` + +## Advanced DI Patterns + +### Scoped Services + +Use different service scopes for workflow operations: + +```typescript +import { injectable, Scope } from '@deepkit/injector'; + +@injectable({ scope: Scope.REQUEST }) +class WorkflowContext { + private data = new Map(); + + set(key: string, value: any): void { + this.data.set(key, value); + } + + get(key: string): T | undefined { + return this.data.get(key); + } + + has(key: string): boolean { + return this.data.has(key); + } +} + +@injectable({ scope: Scope.SINGLETON }) +class AuditService { + private logs: Array<{timestamp: Date, event: string, data: any}> = []; + + log(event: string, data: any): void { + this.logs.push({ + timestamp: new Date(), + event, + data + }); + } + + getLogs(): Array<{timestamp: Date, event: string, data: any}> { + return [...this.logs]; + } +} + +class WorkflowService { + constructor( + private context: WorkflowContext, + private auditService: AuditService + ) {} + + @eventDispatcher.listen(orderWorkflow.onCreated) + async onOrderCreated(event: typeof orderWorkflow.onCreated.event) { + // Store order data in request-scoped context + this.context.set('orderId', event.orderId); + this.context.set('customerId', event.customerId); + + // Log to singleton audit service + this.auditService.log('order_created', { + orderId: event.orderId, + customerId: event.customerId, + amount: event.amount + }); + } + + @eventDispatcher.listen(orderWorkflow.onPaid) + async onPaymentProcessed(event: typeof orderWorkflow.onPaid.event) { + // Access data from context + const orderId = this.context.get('orderId'); + const customerId = this.context.get('customerId'); + + // Log payment + this.auditService.log('payment_processed', { + orderId, + customerId, + transactionId: event.transactionId, + amount: event.amount + }); + } +} +``` + +### Factory Services + +Use factories for dynamic service creation: + +```typescript +interface NotificationService { + send(message: string, recipient: string): Promise; +} + +class EmailNotificationService implements NotificationService { + async send(message: string, recipient: string): Promise { + console.log(`Email to ${recipient}: ${message}`); + } +} + +class SMSNotificationService implements NotificationService { + async send(message: string, recipient: string): Promise { + console.log(`SMS to ${recipient}: ${message}`); + } +} + +class NotificationServiceFactory { + constructor( + private emailService: EmailNotificationService, + private smsService: SMSNotificationService + ) {} + + create(type: 'email' | 'sms'): NotificationService { + switch (type) { + case 'email': + return this.emailService; + case 'sms': + return this.smsService; + default: + throw new Error(`Unknown notification type: ${type}`); + } + } +} + +class NotificationWorkflowListener { + constructor(private notificationFactory: NotificationServiceFactory) {} + + @eventDispatcher.listen(orderWorkflow.onCreated) + async onOrderCreated(event: typeof orderWorkflow.onCreated.event) { + // Use email for order confirmation + const emailService = this.notificationFactory.create('email'); + await emailService.send( + `Order ${event.orderId} created`, + event.customerEmail + ); + } + + @eventDispatcher.listen(orderWorkflow.onShipped) + async onOrderShipped(event: typeof orderWorkflow.onShipped.event) { + // Use SMS for urgent shipping notification + const smsService = this.notificationFactory.create('sms'); + await smsService.send( + `Order ${event.orderId} shipped`, + event.customerPhone + ); + } +} +``` + +### Configuration Injection + +Inject configuration into workflow services: + +```typescript +interface WorkflowConfig { + emailEnabled: boolean; + smsEnabled: boolean; + autoApprovalThreshold: number; + paymentTimeout: number; +} + +const config: WorkflowConfig = { + emailEnabled: true, + smsEnabled: false, + autoApprovalThreshold: 100, + paymentTimeout: 300000, // 5 minutes +}; + +class ConfigurableWorkflowService { + constructor(private config: WorkflowConfig) {} + + @eventDispatcher.listen(orderWorkflow.onCreated) + async onOrderCreated(event: typeof orderWorkflow.onCreated.event) { + // Auto-approve small orders + if (event.amount <= this.config.autoApprovalThreshold) { + event.next('paid', new PaymentProcessedEvent( + event.orderId, + 'auto_approved', + event.amount + )); + return; + } + + // Set payment timeout + setTimeout(() => { + // Handle payment timeout + console.log(`Payment timeout for order ${event.orderId}`); + }, this.config.paymentTimeout); + + event.next('paymentPending'); + } +} + +// Register configuration +const configModule = new InjectorModule([ + { provide: 'WorkflowConfig', useValue: config }, + ConfigurableWorkflowService, +]); +``` + +## Integration with Deepkit Framework + +### Framework Integration + +Use workflows within Deepkit Framework applications: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; +import { http } from '@deepkit/http'; +import { rpc } from '@deepkit/rpc'; + +@http.controller() +class OrderController { + constructor(private dispatcher: EventDispatcher) {} + + @http.POST('/orders') + async createOrder( + @http.body() orderData: { + customerId: string; + customerEmail: string; + amount: number; + } + ) { + const orderId = `order_${Date.now()}`; + const workflow = orderWorkflow.create('created', this.dispatcher); + + await workflow.apply('created', new OrderCreatedEvent( + orderId, + orderData.customerId, + orderData.customerEmail, + orderData.amount + )); + + return { orderId, status: workflow.state.get() }; + } + + @http.GET('/orders/:orderId/status') + async getOrderStatus(@http.param('orderId') orderId: string) { + // In a real app, you'd load the workflow state from storage + // For demo purposes, we'll create a new workflow + const workflow = orderWorkflow.create('created', this.dispatcher); + + return { + orderId, + status: workflow.state.get(), + isDone: workflow.isDone(), + possibleTransitions: workflow.definition.getTransitionsFrom(workflow.state.get()) + }; + } +} + +@rpc.controller('workflow') +class WorkflowController { + constructor(private dispatcher: EventDispatcher) {} + + @rpc.action() + async processOrder(orderId: string, action: string) { + // Load workflow state (simplified) + const workflow = orderWorkflow.create('created', this.dispatcher); + + if (workflow.can(action)) { + await workflow.apply(action); + return { success: true, newState: workflow.state.get() }; + } else { + return { success: false, error: `Cannot transition to ${action}` }; + } + } +} + +const app = new App({ + controllers: [OrderController, WorkflowController], + listeners: [OrderWorkflowListener], + providers: [ + EmailService, + PaymentService, + OrderRepository, + ], + imports: [new FrameworkModule()] +}); +``` + +### Command Integration + +Use workflows in CLI commands: + +```typescript +import { cli } from '@deepkit/app'; + +class WorkflowCommands { + constructor( + private dispatcher: EventDispatcher, + private auditService: AuditService + ) {} + + @cli.command('order:create') + async createOrder( + customerId: string, + email: string, + amount: number + ) { + const orderId = `order_${Date.now()}`; + const workflow = orderWorkflow.create('created', this.dispatcher); + + console.log(`Creating order ${orderId}...`); + + await workflow.apply('created', new OrderCreatedEvent( + orderId, + customerId, + email, + amount + )); + + console.log(`Order created with status: ${workflow.state.get()}`); + } + + @cli.command('workflow:audit') + async showAuditLog() { + const logs = this.auditService.getLogs(); + + console.log('Workflow Audit Log:'); + for (const log of logs) { + console.log(`${log.timestamp.toISOString()} - ${log.event}:`, log.data); + } + } +} + +const app = new App({ + controllers: [WorkflowCommands], + listeners: [OrderWorkflowListener], + providers: [ + EmailService, + PaymentService, + OrderRepository, + AuditService, + ], + imports: [new FrameworkModule()] +}); +``` + +## Testing with Dependency Injection + +### Mocking Services + +Create testable workflows with mocked dependencies: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +// Mock services for testing +class MockEmailService extends EmailService { + sentEmails: Array<{to: string, subject: string, body: string}> = []; + + async sendEmail(to: string, subject: string, body: string): Promise { + this.sentEmails.push({ to, subject, body }); + } +} + +class MockPaymentService extends PaymentService { + shouldSucceed = true; + + async processPayment(amount: number, customerId: string): Promise<{success: boolean, transactionId?: string}> { + return this.shouldSucceed + ? { success: true, transactionId: 'mock_txn_123' } + : { success: false }; + } +} + +// Test +test('order workflow with mocked services', async () => { + const testing = createTestingApp({ + listeners: [OrderWorkflowListener], + providers: [ + { provide: EmailService, useClass: MockEmailService }, + { provide: PaymentService, useClass: MockPaymentService }, + OrderRepository, + ], + }); + + const dispatcher = testing.app.get(EventDispatcher); + const emailService = testing.app.get(EmailService) as MockEmailService; + const paymentService = testing.app.get(PaymentService) as MockPaymentService; + + const workflow = orderWorkflow.create('created', dispatcher); + + // Test successful flow + await workflow.apply('created', new OrderCreatedEvent( + 'test_order', + 'customer_123', + 'test@example.com', + 99.99 + )); + + // Verify email was sent + expect(emailService.sentEmails).toHaveLength(1); + expect(emailService.sentEmails[0].to).toBe('test@example.com'); + expect(emailService.sentEmails[0].subject).toBe('Order Confirmation'); + + // Verify workflow progressed + expect(workflow.state.get()).toBe('paid'); + + // Test payment failure + paymentService.shouldSucceed = false; + const failedWorkflow = orderWorkflow.create('created', dispatcher); + + await failedWorkflow.apply('created', new OrderCreatedEvent( + 'failed_order', + 'customer_456', + 'fail@example.com', + 199.99 + )); + + // Should stay in paymentPending state + expect(failedWorkflow.state.get()).toBe('paymentPending'); +}); +``` + +## Best Practices + +1. **Use Constructor Injection**: Prefer constructor injection for required dependencies +2. **Scope Services Appropriately**: Use singleton for stateless services, request scope for contextual data +3. **Mock External Dependencies**: Create mock implementations for testing +4. **Separate Concerns**: Keep business logic in services, workflow logic in listeners +5. **Use Configuration**: Make workflows configurable through dependency injection +6. **Handle Errors Gracefully**: Inject error handling services for robust workflows +7. **Audit and Logging**: Use injected services for comprehensive workflow auditing + +## Next Steps + +- [Testing](./testing.md) - Comprehensive testing strategies for DI-enabled workflows +- [Advanced Features](./advanced-features.md) - Performance optimization and debugging +- [State Management](./state-management.md) - Advanced state patterns with DI +- [Events](./events.md) - Event handling with dependency injection diff --git a/website/src/pages/documentation/workflow/events.md b/website/src/pages/documentation/workflow/events.md new file mode 100644 index 000000000..d13b67385 --- /dev/null +++ b/website/src/pages/documentation/workflow/events.md @@ -0,0 +1,469 @@ +# Workflow Events + +Workflow events are the heart of Deepkit's workflow engine, providing a powerful way to handle state transitions and implement business logic. This guide covers advanced event handling patterns and automatic state progression. + +## Event Basics + +### WorkflowEvent Base Class + +All workflow events extend the `WorkflowEvent` base class: + +```typescript +import { WorkflowEvent } from '@deepkit/workflow'; + +class CustomEvent extends WorkflowEvent { + constructor( + public data: string, + public timestamp: Date = new Date() + ) { + super(); + } +} +``` + +The `WorkflowEvent` class provides methods for controlling workflow progression: + +- `next(nextState, event?)` - Schedule the next state transition +- `hasNext()` - Check if a next state is scheduled +- `clearNext()` - Clear any scheduled next state + +### Event Tokens + +Each state in a workflow automatically generates an event token that you can listen to: + +```typescript +const workflow = createWorkflow('example', { + start: WorkflowEvent, + processing: WorkflowEvent, + completed: CompletedEvent, +}, { + start: 'processing', + processing: 'completed' +}); + +// Event tokens are automatically created with 'on' prefix +console.log(workflow.onStart); // EventToken for 'start' state +console.log(workflow.onProcessing); // EventToken for 'processing' state +console.log(workflow.onCompleted); // EventToken for 'completed' state +``` + +## Event Listeners + +### Functional Listeners + +Listen to workflow events using functional listeners: + +```typescript +import { EventDispatcher } from '@deepkit/event'; + +const dispatcher = new EventDispatcher(); +const workflow = exampleWorkflow.create('start', dispatcher); + +// Basic listener +dispatcher.listen(workflow.onProcessing, async (event) => { + console.log('Processing started'); +}); + +// Listener with order (lower numbers execute first) +dispatcher.listen(workflow.onProcessing, async (event) => { + console.log('This runs first'); +}, -10); + +dispatcher.listen(workflow.onProcessing, async (event) => { + console.log('This runs second'); +}, 0); + +// Unsubscribe from events +const unsubscribe = dispatcher.listen(workflow.onCompleted, async (event) => { + console.log('Completed'); +}); + +// Later... +unsubscribe(); +``` + +### Class-based Listeners + +Use decorators for organized, dependency-injectable listeners: + +```typescript +import { eventDispatcher } from '@deepkit/event'; + +class WorkflowService { + constructor( + private logger: Logger, + private emailService: EmailService + ) {} + + @eventDispatcher.listen(exampleWorkflow.onStart) + async onWorkflowStart(event: typeof exampleWorkflow.onStart.event) { + this.logger.info('Workflow started'); + } + + @eventDispatcher.listen(exampleWorkflow.onProcessing, -5) // High priority + async onProcessingStarted(event: typeof exampleWorkflow.onProcessing.event) { + this.logger.info('Processing phase started'); + // This runs before other processing listeners + } + + @eventDispatcher.listen(exampleWorkflow.onCompleted) + async onWorkflowCompleted(event: typeof exampleWorkflow.onCompleted.event) { + await this.emailService.sendCompletionNotification(event.data); + this.logger.info('Workflow completed successfully'); + } +} +``` + +## Automatic State Progression + +One of the most powerful features is automatic state progression using the `next()` method: + +### Basic Next State + +```typescript +const approvalWorkflow = createWorkflow('approval', { + submitted: WorkflowEvent, + reviewing: WorkflowEvent, + approved: WorkflowEvent, + rejected: WorkflowEvent, +}, { + submitted: 'reviewing', + reviewing: ['approved', 'rejected'], +}); + +dispatcher.listen(approvalWorkflow.onSubmitted, async (event) => { + // Automatically start review process + console.log('Starting automatic review...'); + + // Simulate review logic + const shouldApprove = await performAutomaticReview(); + + if (shouldApprove) { + event.next('reviewing'); // Will transition to 'reviewing' after this listener + } +}); + +dispatcher.listen(approvalWorkflow.onReviewing, async (event) => { + // Perform review and decide outcome + const reviewResult = await conductReview(); + + if (reviewResult.approved) { + event.next('approved'); + } else { + event.next('rejected'); + } +}); + +// Start the workflow - it will automatically progress through states +const workflow = approvalWorkflow.create('submitted', dispatcher); +await workflow.apply('submitted'); + +// After the above call, the workflow might be in 'approved' or 'rejected' state +console.log('Final state:', workflow.state.get()); +``` + +### Next State with Custom Events + +You can provide custom events when transitioning to the next state: + +```typescript +class ApprovedEvent extends WorkflowEvent { + constructor( + public approvedBy: string, + public approvalDate: Date, + public comments: string + ) { + super(); + } +} + +class RejectedEvent extends WorkflowEvent { + constructor( + public rejectedBy: string, + public rejectionDate: Date, + public reason: string + ) { + super(); + } +} + +const approvalWorkflow = createWorkflow('approval', { + submitted: WorkflowEvent, + reviewing: WorkflowEvent, + approved: ApprovedEvent, + rejected: RejectedEvent, +}, { + submitted: 'reviewing', + reviewing: ['approved', 'rejected'], +}); + +dispatcher.listen(approvalWorkflow.onReviewing, async (event) => { + const review = await conductReview(); + + if (review.approved) { + event.next('approved', new ApprovedEvent( + review.reviewerId, + new Date(), + review.comments + )); + } else { + event.next('rejected', new RejectedEvent( + review.reviewerId, + new Date(), + review.rejectionReason + )); + } +}); + +// Listen to the final states +dispatcher.listen(approvalWorkflow.onApproved, async (event) => { + console.log(`Approved by ${event.approvedBy} on ${event.approvalDate}`); + console.log(`Comments: ${event.comments}`); +}); + +dispatcher.listen(approvalWorkflow.onRejected, async (event) => { + console.log(`Rejected by ${event.rejectedBy}: ${event.reason}`); +}); +``` + +### Chained State Progression + +You can chain multiple state transitions: + +```typescript +const orderWorkflow = createWorkflow('order', { + created: WorkflowEvent, + validated: WorkflowEvent, + paymentProcessing: WorkflowEvent, + paid: WorkflowEvent, + shipped: WorkflowEvent, +}, { + created: 'validated', + validated: 'paymentProcessing', + paymentProcessing: 'paid', + paid: 'shipped', +}); + +dispatcher.listen(orderWorkflow.onCreated, async (event) => { + // Validate order + const isValid = await validateOrder(); + if (isValid) { + event.next('validated'); + } +}); + +dispatcher.listen(orderWorkflow.onValidated, async (event) => { + // Start payment processing + event.next('paymentProcessing'); +}); + +dispatcher.listen(orderWorkflow.onPaymentProcessing, async (event) => { + // Process payment + const paymentResult = await processPayment(); + if (paymentResult.success) { + event.next('paid'); + } +}); + +dispatcher.listen(orderWorkflow.onPaid, async (event) => { + // Ship the order + await shipOrder(); + event.next('shipped'); +}); + +// Start the workflow - it will progress through all states automatically +const workflow = orderWorkflow.create('created', dispatcher); +await workflow.apply('created'); + +// The workflow will end up in 'shipped' state if all steps succeed +console.log('Final state:', workflow.state.get()); // 'shipped' +``` + +## Event Data and Context + +### Accessing Event Data + +Custom events can carry data that's accessible in listeners: + +```typescript +class OrderCreatedEvent extends WorkflowEvent { + constructor( + public orderId: string, + public customerId: string, + public items: OrderItem[], + public totalAmount: number + ) { + super(); + } +} + +dispatcher.listen(orderWorkflow.onCreated, async (event) => { + // Access event data + console.log(`Order ${event.orderId} created for customer ${event.customerId}`); + console.log(`Total amount: $${event.totalAmount}`); + console.log(`Items: ${event.items.length}`); + + // Use the data for business logic + if (event.totalAmount > 1000) { + // High-value order processing + await processHighValueOrder(event); + } +}); +``` + +### Event Context and State + +Events have access to workflow context through the event dispatcher: + +```typescript +dispatcher.listen(orderWorkflow.onPaymentProcessing, async (event) => { + // You can access other services through dependency injection + // or store context in the event dispatcher + + const orderData = event.orderId; // From the event + const currentState = workflow.state.get(); // Current workflow state + + console.log(`Processing payment for order ${orderData} in state ${currentState}`); +}); +``` + +## Error Handling in Events + +### Validation Errors + +Handle validation errors in event listeners: + +```typescript +dispatcher.listen(orderWorkflow.onCreated, async (event) => { + try { + await validateOrder(event.orderId); + event.next('validated'); + } catch (error) { + console.error('Order validation failed:', error.message); + // Don't call next() - workflow stays in current state + // Or transition to an error state if defined + // event.next('validationFailed'); + } +}); +``` + +### Preventing State Transitions + +Use event control methods to prevent transitions: + +```typescript +import { BaseEvent } from '@deepkit/event'; + +dispatcher.listen(orderWorkflow.onPaymentProcessing, async (event) => { + const creditCheck = await performCreditCheck(event.customerId); + + if (!creditCheck.passed) { + // Prevent the transition from completing + event.preventDefault(); + console.log('Payment processing prevented due to credit check failure'); + return; + } + + // Continue with payment processing + const result = await processPayment(event); + if (result.success) { + event.next('paid'); + } +}); +``` + +### Error States + +Define explicit error states in your workflow: + +```typescript +const robustOrderWorkflow = createWorkflow('robustOrder', { + created: WorkflowEvent, + validated: WorkflowEvent, + validationFailed: WorkflowEvent, + paymentProcessing: WorkflowEvent, + paid: WorkflowEvent, + paymentFailed: WorkflowEvent, + shipped: WorkflowEvent, +}, { + created: ['validated', 'validationFailed'], + validated: 'paymentProcessing', + validationFailed: [], // Terminal state + paymentProcessing: ['paid', 'paymentFailed'], + paymentFailed: [], // Terminal state + paid: 'shipped', +}); + +dispatcher.listen(robustOrderWorkflow.onCreated, async (event) => { + try { + await validateOrder(event.orderId); + event.next('validated'); + } catch (error) { + event.next('validationFailed'); + } +}); + +dispatcher.listen(robustOrderWorkflow.onPaymentProcessing, async (event) => { + try { + const result = await processPayment(event); + if (result.success) { + event.next('paid'); + } else { + event.next('paymentFailed'); + } + } catch (error) { + event.next('paymentFailed'); + } +}); +``` + +## Performance Considerations + +### Event Listener Order + +Control the execution order of listeners for performance: + +```typescript +// High-priority validation (runs first) +dispatcher.listen(orderWorkflow.onCreated, async (event) => { + await quickValidation(event); +}, -100); + +// Normal processing (runs second) +dispatcher.listen(orderWorkflow.onCreated, async (event) => { + await normalProcessing(event); +}, 0); + +// Logging and analytics (runs last) +dispatcher.listen(orderWorkflow.onCreated, async (event) => { + await logEvent(event); + await trackAnalytics(event); +}, 100); +``` + +### Conditional Event Processing + +Only process events when necessary: + +```typescript +dispatcher.listen(orderWorkflow.onCreated, async (event) => { + // Skip processing for test orders + if (event.orderId.startsWith('TEST_')) { + return; + } + + // Only process high-value orders differently + if (event.totalAmount > 1000) { + await specialHighValueProcessing(event); + } + + await standardProcessing(event); +}); +``` + +## Next Steps + +- [State Management](./state-management.md) - Advanced state transition patterns +- [Dependency Injection](./dependency-injection.md) - Using DI with workflow events +- [Testing](./testing.md) - Testing event-driven workflows +- [Advanced Features](./advanced-features.md) - Performance optimization and debugging diff --git a/website/src/pages/documentation/workflow/getting-started.md b/website/src/pages/documentation/workflow/getting-started.md new file mode 100644 index 000000000..43a459d67 --- /dev/null +++ b/website/src/pages/documentation/workflow/getting-started.md @@ -0,0 +1,379 @@ +# Getting Started with Workflows + +This guide will walk you through creating your first workflow using Deepkit's workflow engine, from basic concepts to practical examples. + +## Installation + +```bash +npm install @deepkit/workflow @deepkit/event @deepkit/injector +``` + +## Basic Workflow Creation + +Let's start with a simple user registration workflow: + +```typescript +import { createWorkflow, WorkflowEvent } from '@deepkit/workflow'; +import { EventDispatcher } from '@deepkit/event'; + +// Step 1: Define custom events for states that need data +class UserRegisteredEvent extends WorkflowEvent { + constructor( + public userId: string, + public email: string, + public username: string + ) { + super(); + } +} + +class EmailVerifiedEvent extends WorkflowEvent { + constructor( + public userId: string, + public verificationToken: string + ) { + super(); + } +} + +// Step 2: Create the workflow definition +const userRegistrationWorkflow = createWorkflow('userRegistration', { + // Define all possible states and their event types + pending: WorkflowEvent, + registered: UserRegisteredEvent, + emailSent: WorkflowEvent, + verified: EmailVerifiedEvent, + active: WorkflowEvent, + suspended: WorkflowEvent, +}, { + // Define allowed transitions between states + pending: 'registered', + registered: 'emailSent', + emailSent: ['verified', 'suspended'], // Multiple possible next states + verified: 'active', + // active and suspended are final states (no outgoing transitions) +}); + +// Step 3: Create a workflow instance +const dispatcher = new EventDispatcher(); +const userWorkflow = userRegistrationWorkflow.create('pending', dispatcher); + +console.log(userWorkflow.state.get()); // 'pending' +console.log(userWorkflow.isDone()); // false +``` + +## Understanding States and Transitions + +### State Definitions + +Each state in a workflow must be associated with an event class: + +```typescript +const workflow = createWorkflow('example', { + // Simple states use the base WorkflowEvent + start: WorkflowEvent, + processing: WorkflowEvent, + + // Complex states use custom event classes with data + completed: CompletedEvent, + failed: FailedEvent, +}, transitions); +``` + +### Transition Definitions + +Transitions define which states can follow the current state: + +```typescript +const transitions = { + // Single transition: start can only go to processing + start: 'processing', + + // Multiple transitions: processing can go to completed OR failed + processing: ['completed', 'failed'], + + // No transitions: completed and failed are final states + // (omitted from transitions object) +}; +``` + +## Applying State Transitions + +### Basic Transitions + +```typescript +// Check if a transition is possible +console.log(userWorkflow.can('registered')); // true +console.log(userWorkflow.can('active')); // false (not directly reachable) + +// Apply a transition +await userWorkflow.apply('registered', new UserRegisteredEvent( + 'user-123', + 'john@example.com', + 'john_doe' +)); + +console.log(userWorkflow.state.get()); // 'registered' +``` + +### Error Handling + +The workflow engine validates all transitions: + +```typescript +try { + // This will fail - can't go directly from 'registered' to 'active' + await userWorkflow.apply('active'); +} catch (error) { + console.log(error.message); + // "Can not apply state change from registered->active" +} + +try { + // This will fail - wrong event type + await userWorkflow.apply('emailSent', new UserRegisteredEvent('123', 'test', 'test')); +} catch (error) { + console.log(error.message); + // "State emailSent got the wrong event. Expected WorkflowEvent, got UserRegisteredEvent" +} +``` + +## Working with Event Listeners + +### Functional Listeners + +Add business logic by listening to workflow events: + +```typescript +// Listen to the 'registered' state transition +dispatcher.listen(userRegistrationWorkflow.onRegistered, async (event) => { + console.log(`User registered: ${event.username} (${event.email})`); + + // Send welcome email + await sendWelcomeEmail(event.email, event.username); + + // Log to analytics + await trackUserRegistration(event.userId); +}); + +// Listen to email verification +dispatcher.listen(userRegistrationWorkflow.onVerified, async (event) => { + console.log(`Email verified for user: ${event.userId}`); + + // Update user status in database + await updateUserStatus(event.userId, 'verified'); +}); +``` + +### Class-based Listeners + +For better organization, use class-based listeners: + +```typescript +import { eventDispatcher } from '@deepkit/event'; + +class UserRegistrationService { + constructor( + private emailService: EmailService, + private userRepository: UserRepository, + private analyticsService: AnalyticsService + ) {} + + @eventDispatcher.listen(userRegistrationWorkflow.onRegistered) + async onUserRegistered(event: typeof userRegistrationWorkflow.onRegistered.event) { + // Send welcome email + await this.emailService.sendWelcome(event.email, event.username); + + // Track registration + await this.analyticsService.track('user_registered', { + userId: event.userId, + email: event.email + }); + } + + @eventDispatcher.listen(userRegistrationWorkflow.onEmailSent) + async onEmailSent(event: typeof userRegistrationWorkflow.onEmailSent.event) { + // Set up email verification timeout + setTimeout(async () => { + const user = await this.userRepository.findById(event.userId); + if (user && user.status === 'emailSent') { + // Suspend user if email not verified within 24 hours + await userWorkflow.apply('suspended'); + } + }, 24 * 60 * 60 * 1000); // 24 hours + } + + @eventDispatcher.listen(userRegistrationWorkflow.onVerified) + async onEmailVerified(event: typeof userRegistrationWorkflow.onVerified.event) { + // Update user in database + await this.userRepository.updateStatus(event.userId, 'verified'); + + // Send confirmation + const user = await this.userRepository.findById(event.userId); + await this.emailService.sendVerificationConfirmation(user.email); + } +} +``` + +## Complete Example: Order Processing + +Here's a complete example of an order processing workflow: + +```typescript +import { createWorkflow, WorkflowEvent } from '@deepkit/workflow'; +import { EventDispatcher } from '@deepkit/event'; +import { eventDispatcher } from '@deepkit/event'; + +// Custom events with relevant data +class OrderCreatedEvent extends WorkflowEvent { + constructor( + public orderId: string, + public customerId: string, + public items: Array<{productId: string, quantity: number}>, + public totalAmount: number + ) { + super(); + } +} + +class PaymentProcessedEvent extends WorkflowEvent { + constructor( + public orderId: string, + public paymentId: string, + public amount: number + ) { + super(); + } +} + +class OrderShippedEvent extends WorkflowEvent { + constructor( + public orderId: string, + public trackingNumber: string, + public carrier: string + ) { + super(); + } +} + +// Workflow definition +const orderWorkflow = createWorkflow('orderProcessing', { + created: OrderCreatedEvent, + paymentPending: WorkflowEvent, + paid: PaymentProcessedEvent, + preparing: WorkflowEvent, + shipped: OrderShippedEvent, + delivered: WorkflowEvent, + cancelled: WorkflowEvent, + refunded: WorkflowEvent, +}, { + created: 'paymentPending', + paymentPending: ['paid', 'cancelled'], + paid: ['preparing', 'refunded'], + preparing: ['shipped', 'cancelled'], + shipped: ['delivered'], + cancelled: 'refunded', + // delivered and refunded are final states +}); + +// Business logic services +class OrderService { + @eventDispatcher.listen(orderWorkflow.onCreated) + async onOrderCreated(event: typeof orderWorkflow.onCreated.event) { + console.log(`Order created: ${event.orderId} for customer ${event.customerId}`); + + // Reserve inventory + await this.reserveInventory(event.items); + + // Send order confirmation + await this.sendOrderConfirmation(event.customerId, event.orderId); + } + + @eventDispatcher.listen(orderWorkflow.onPaid) + async onPaymentProcessed(event: typeof orderWorkflow.onPaid.event) { + console.log(`Payment processed: ${event.paymentId} for order ${event.orderId}`); + + // Start preparation process + await this.startPreparation(event.orderId); + } + + @eventDispatcher.listen(orderWorkflow.onShipped) + async onOrderShipped(event: typeof orderWorkflow.onShipped.event) { + console.log(`Order shipped: ${event.orderId} via ${event.carrier}`); + + // Send tracking information + await this.sendTrackingInfo(event.orderId, event.trackingNumber, event.carrier); + } + + private async reserveInventory(items: Array<{productId: string, quantity: number}>) { + // Implementation + } + + private async sendOrderConfirmation(customerId: string, orderId: string) { + // Implementation + } + + private async startPreparation(orderId: string) { + // Implementation + } + + private async sendTrackingInfo(orderId: string, trackingNumber: string, carrier: string) { + // Implementation + } +} + +// Usage +async function processOrder() { + const dispatcher = new EventDispatcher(); + const workflow = orderWorkflow.create('created', dispatcher); + + // Register service + const orderService = new OrderService(); + // In a real app, you'd use dependency injection here + + // Start the workflow + await workflow.apply('created', new OrderCreatedEvent( + 'order-123', + 'customer-456', + [ + { productId: 'product-1', quantity: 2 }, + { productId: 'product-2', quantity: 1 } + ], + 299.99 + )); + + // Process payment + await workflow.apply('paymentPending'); + await workflow.apply('paid', new PaymentProcessedEvent( + 'order-123', + 'payment-789', + 299.99 + )); + + // Prepare and ship + await workflow.apply('preparing'); + await workflow.apply('shipped', new OrderShippedEvent( + 'order-123', + 'TRACK123456', + 'UPS' + )); + + // Final delivery + await workflow.apply('delivered'); + + console.log('Order completed!'); + console.log('Final state:', workflow.state.get()); // 'delivered' + console.log('Is done:', workflow.isDone()); // true +} + +processOrder().catch(console.error); +``` + +## Next Steps + +Now that you understand the basics, explore more advanced features: + +- [Events](./events.md) - Advanced event handling and automatic state progression +- [State Management](./state-management.md) - Complex state patterns and validation +- [Dependency Injection](./dependency-injection.md) - Integration with Deepkit's DI system +- [Testing](./testing.md) - Testing strategies for workflows diff --git a/website/src/pages/documentation/workflow/state-management.md b/website/src/pages/documentation/workflow/state-management.md new file mode 100644 index 000000000..8d2024a02 --- /dev/null +++ b/website/src/pages/documentation/workflow/state-management.md @@ -0,0 +1,576 @@ +# State Management + +Effective state management is crucial for building robust workflows. This guide covers advanced patterns for managing workflow states, transitions, and validation. + +## State Design Patterns + +### Linear Workflows + +Simple workflows with sequential states: + +```typescript +import { createWorkflow, WorkflowEvent } from '@deepkit/workflow'; + +const documentWorkflow = createWorkflow('document', { + draft: WorkflowEvent, + review: WorkflowEvent, + approved: WorkflowEvent, + published: WorkflowEvent, +}, { + draft: 'review', + review: 'approved', + approved: 'published', +}); +``` + +### Branching Workflows + +Workflows with multiple possible paths: + +```typescript +const loanApplicationWorkflow = createWorkflow('loanApplication', { + submitted: WorkflowEvent, + initialReview: WorkflowEvent, + approved: WorkflowEvent, + rejected: WorkflowEvent, + manualReview: WorkflowEvent, + finalApproval: WorkflowEvent, + finalRejection: WorkflowEvent, +}, { + submitted: 'initialReview', + initialReview: ['approved', 'rejected', 'manualReview'], + manualReview: ['finalApproval', 'finalRejection'], + // approved, rejected, finalApproval, finalRejection are terminal states +}); +``` + +### Parallel State Workflows + +Handle multiple concurrent processes: + +```typescript +class OrderItemEvent extends WorkflowEvent { + constructor(public itemId: string, public status: string) { + super(); + } +} + +const orderFulfillmentWorkflow = createWorkflow('orderFulfillment', { + created: WorkflowEvent, + inventoryCheck: WorkflowEvent, + paymentProcessing: WorkflowEvent, + inventoryReserved: OrderItemEvent, + paymentCompleted: WorkflowEvent, + readyToShip: WorkflowEvent, + shipped: WorkflowEvent, +}, { + created: ['inventoryCheck', 'paymentProcessing'], // Parallel processes + inventoryCheck: 'inventoryReserved', + paymentProcessing: 'paymentCompleted', + // Both must complete before shipping + inventoryReserved: 'readyToShip', + paymentCompleted: 'readyToShip', + readyToShip: 'shipped', +}); +``` + +### Cyclical Workflows + +Workflows that can return to previous states: + +```typescript +const ticketWorkflow = createWorkflow('ticket', { + open: WorkflowEvent, + inProgress: WorkflowEvent, + waitingForCustomer: WorkflowEvent, + resolved: WorkflowEvent, + closed: WorkflowEvent, + reopened: WorkflowEvent, +}, { + open: 'inProgress', + inProgress: ['waitingForCustomer', 'resolved'], + waitingForCustomer: ['inProgress', 'closed'], // Can go back to inProgress + resolved: ['closed', 'reopened'], // Can be reopened + reopened: 'inProgress', // Back to processing + // closed is terminal +}); +``` + +## State Validation + +### Business Rule Validation + +Implement complex business rules in state transitions: + +```typescript +class ExpenseEvent extends WorkflowEvent { + constructor( + public amount: number, + public category: string, + public submittedBy: string + ) { + super(); + } +} + +const expenseWorkflow = createWorkflow('expense', { + submitted: ExpenseEvent, + managerReview: WorkflowEvent, + financeReview: WorkflowEvent, + approved: WorkflowEvent, + rejected: WorkflowEvent, +}, { + submitted: ['managerReview', 'financeReview', 'approved'], // Multiple paths based on amount + managerReview: ['approved', 'rejected', 'financeReview'], + financeReview: ['approved', 'rejected'], +}); + +// Business rule: expenses over $1000 need finance review +dispatcher.listen(expenseWorkflow.onSubmitted, async (event) => { + if (event.amount > 1000) { + event.next('financeReview'); + } else if (event.amount > 100) { + event.next('managerReview'); + } else { + // Auto-approve small expenses + event.next('approved'); + } +}); + +// Manager can escalate to finance +dispatcher.listen(expenseWorkflow.onManagerReview, async (event) => { + const decision = await getManagerDecision(event.submittedBy); + + switch (decision.action) { + case 'approve': + event.next('approved'); + break; + case 'reject': + event.next('rejected'); + break; + case 'escalate': + event.next('financeReview'); + break; + } +}); +``` + +### Conditional Transitions + +Use guards to control when transitions are allowed: + +```typescript +class GuardedWorkflow { + private conditions = new Map(); + + setCondition(name: string, value: boolean) { + this.conditions.set(name, value); + } + + checkCondition(name: string): boolean { + return this.conditions.get(name) ?? false; + } +} + +const guardedWorkflow = new GuardedWorkflow(); + +const deploymentWorkflow = createWorkflow('deployment', { + pending: WorkflowEvent, + testing: WorkflowEvent, + staging: WorkflowEvent, + production: WorkflowEvent, + rollback: WorkflowEvent, +}, { + pending: 'testing', + testing: ['staging', 'rollback'], + staging: ['production', 'rollback'], + production: 'rollback', // Can rollback from production +}); + +dispatcher.listen(deploymentWorkflow.onTesting, async (event) => { + const testsPass = await runTests(); + guardedWorkflow.setCondition('testsPass', testsPass); + + if (testsPass) { + event.next('staging'); + } else { + event.next('rollback'); + } +}); + +dispatcher.listen(deploymentWorkflow.onStaging, async (event) => { + const stagingValid = await validateStaging(); + const approvalReceived = guardedWorkflow.checkCondition('approvalReceived'); + + if (stagingValid && approvalReceived) { + event.next('production'); + } else { + event.next('rollback'); + } +}); +``` + +## State Persistence + +### Custom State Storage + +Implement custom state storage for persistence: + +```typescript +interface WorkflowState { + get(): keyof T & string; + set(v: keyof T & string): void; +} + +class DatabaseWorkflowState implements WorkflowState { + constructor( + private workflowId: string, + private database: Database, + private initialState: keyof T & string + ) {} + + get(): keyof T & string { + // Load from database + const record = this.database.query( + 'SELECT state FROM workflows WHERE id = ?', + [this.workflowId] + ); + return record?.state || this.initialState; + } + + set(state: keyof T & string): void { + // Save to database + this.database.execute( + 'UPDATE workflows SET state = ?, updated_at = NOW() WHERE id = ?', + [state, this.workflowId] + ); + } +} + +// Use custom state storage +const customState = new DatabaseWorkflowState('workflow-123', database, 'pending'); +const workflow = new Workflow(definition, customState, dispatcher, injector); +``` + +### State History + +Track state transition history: + +```typescript +class StateHistoryTracker implements WorkflowState { + private history: Array<{ + state: keyof T & string; + timestamp: Date; + event?: any; + }> = []; + + constructor(private currentState: keyof T & string) { + this.history.push({ + state: currentState, + timestamp: new Date() + }); + } + + get(): keyof T & string { + return this.currentState; + } + + set(state: keyof T & string): void { + this.history.push({ + state, + timestamp: new Date() + }); + this.currentState = state; + } + + getHistory() { + return [...this.history]; + } + + getPreviousState(): keyof T & string | undefined { + return this.history[this.history.length - 2]?.state; + } + + getStateAt(timestamp: Date): keyof T & string | undefined { + const entry = this.history + .filter(h => h.timestamp <= timestamp) + .pop(); + return entry?.state; + } +} +``` + +## State Queries and Inspection + +### Workflow State Inspection + +Query workflow state and capabilities: + +```typescript +class WorkflowInspector { + constructor(private workflow: Workflow) {} + + getCurrentState(): keyof T & string { + return this.workflow.state.get(); + } + + getPossibleTransitions(): (keyof T & string)[] { + return this.workflow.definition.getTransitionsFrom(this.getCurrentState()); + } + + canTransitionTo(state: keyof T & string): boolean { + return this.workflow.can(state); + } + + isInFinalState(): boolean { + return this.workflow.isDone(); + } + + getReachableStates(): (keyof T & string)[] { + const visited = new Set(); + const queue = [this.getCurrentState()]; + + while (queue.length > 0) { + const current = queue.shift()!; + if (visited.has(current)) continue; + + visited.add(current); + const transitions = this.workflow.definition.getTransitionsFrom(current); + queue.push(...transitions); + } + + return Array.from(visited); + } + + getShortestPathTo(targetState: keyof T & string): (keyof T & string)[] | null { + const queue: Array<{state: keyof T & string, path: (keyof T & string)[]}> = [ + { state: this.getCurrentState(), path: [this.getCurrentState()] } + ]; + const visited = new Set(); + + while (queue.length > 0) { + const { state, path } = queue.shift()!; + + if (state === targetState) { + return path; + } + + if (visited.has(state)) continue; + visited.add(state); + + const transitions = this.workflow.definition.getTransitionsFrom(state); + for (const nextState of transitions) { + queue.push({ + state: nextState, + path: [...path, nextState] + }); + } + } + + return null; // No path found + } +} + +// Usage +const inspector = new WorkflowInspector(orderWorkflow); + +console.log('Current state:', inspector.getCurrentState()); +console.log('Possible transitions:', inspector.getPossibleTransitions()); +console.log('Can ship?', inspector.canTransitionTo('shipped')); +console.log('Is done?', inspector.isInFinalState()); +console.log('Reachable states:', inspector.getReachableStates()); +console.log('Path to delivered:', inspector.getShortestPathTo('delivered')); +``` + +### Workflow Visualization + +Generate workflow diagrams: + +```typescript +class WorkflowVisualizer { + constructor(private definition: WorkflowDefinition) {} + + generateMermaidDiagram(): string { + const states = Object.keys(this.definition.places); + const transitions = this.definition.transitions; + + let diagram = 'stateDiagram-v2\n'; + + // Add states + for (const state of states) { + diagram += ` ${state}\n`; + } + + // Add transitions + for (const transition of transitions) { + diagram += ` ${transition.from} --> ${transition.to}`; + if (transition.label) { + diagram += ` : ${transition.label}`; + } + diagram += '\n'; + } + + return diagram; + } + + generateDotGraph(): string { + const states = Object.keys(this.definition.places); + const transitions = this.definition.transitions; + + let graph = 'digraph workflow {\n'; + graph += ' rankdir=LR;\n'; + + // Add states + for (const state of states) { + const isTerminal = this.definition.getTransitionsFrom(state).length === 0; + const shape = isTerminal ? 'doublecircle' : 'circle'; + graph += ` ${state} [shape=${shape}];\n`; + } + + // Add transitions + for (const transition of transitions) { + graph += ` ${transition.from} -> ${transition.to}`; + if (transition.label) { + graph += ` [label="${transition.label}"]`; + } + graph += ';\n'; + } + + graph += '}'; + return graph; + } +} + +// Usage +const visualizer = new WorkflowVisualizer(orderWorkflow); +console.log(visualizer.generateMermaidDiagram()); +console.log(visualizer.generateDotGraph()); +``` + +## State Machine Patterns + +### State Pattern Implementation + +Implement the State pattern with workflows: + +```typescript +abstract class OrderState { + abstract handle(context: OrderContext): Promise; + abstract canTransitionTo(state: string): boolean; +} + +class OrderContext { + constructor( + private workflow: Workflow, + private orderData: any + ) {} + + async setState(stateName: string, event?: any) { + await this.workflow.apply(stateName, event); + } + + getCurrentState(): string { + return this.workflow.state.get(); + } + + canTransitionTo(state: string): boolean { + return this.workflow.can(state); + } +} + +class CreatedState extends OrderState { + async handle(context: OrderContext): Promise { + // Validate order + const isValid = await this.validateOrder(context); + if (isValid) { + await context.setState('processing'); + } else { + await context.setState('cancelled'); + } + } + + canTransitionTo(state: string): boolean { + return ['processing', 'cancelled'].includes(state); + } + + private async validateOrder(context: OrderContext): Promise { + // Validation logic + return true; + } +} + +class ProcessingState extends OrderState { + async handle(context: OrderContext): Promise { + // Process payment + const paymentResult = await this.processPayment(context); + if (paymentResult.success) { + await context.setState('paid'); + } else { + await context.setState('paymentFailed'); + } + } + + canTransitionTo(state: string): boolean { + return ['paid', 'paymentFailed', 'cancelled'].includes(state); + } + + private async processPayment(context: OrderContext): Promise<{success: boolean}> { + // Payment processing logic + return { success: true }; + } +} +``` + +### Hierarchical State Machines + +Implement nested state machines: + +```typescript +const parentWorkflow = createWorkflow('parent', { + active: WorkflowEvent, + inactive: WorkflowEvent, +}, { + active: 'inactive', + inactive: 'active', +}); + +const childWorkflow = createWorkflow('child', { + idle: WorkflowEvent, + working: WorkflowEvent, + completed: WorkflowEvent, +}, { + idle: 'working', + working: 'completed', + completed: 'idle', +}); + +class HierarchicalWorkflowManager { + constructor( + private parent: Workflow, + private child: Workflow + ) {} + + async activateParent() { + await this.parent.apply('active'); + // Child can only work when parent is active + if (this.child.state.get() === 'idle') { + await this.child.apply('working'); + } + } + + async deactivateParent() { + // Stop child work when parent becomes inactive + if (this.child.state.get() === 'working') { + await this.child.apply('completed'); + } + await this.parent.apply('inactive'); + } +} +``` + +## Next Steps + +- [Dependency Injection](./dependency-injection.md) - Integrating workflows with DI +- [Testing](./testing.md) - Testing complex state management +- [Advanced Features](./advanced-features.md) - Performance and debugging +- [Events](./events.md) - Advanced event handling patterns diff --git a/website/src/pages/documentation/workflow/testing.md b/website/src/pages/documentation/workflow/testing.md new file mode 100644 index 000000000..1e0152a54 --- /dev/null +++ b/website/src/pages/documentation/workflow/testing.md @@ -0,0 +1,564 @@ +# Testing Workflows + +Comprehensive testing is crucial for workflow-driven applications. This guide covers testing strategies, patterns, and tools for ensuring your workflows behave correctly under all conditions. + +## Basic Workflow Testing + +### Unit Testing Workflow Logic + +Test individual workflow transitions and state changes: + +```typescript +import { expect, test } from '@jest/globals'; +import { createWorkflow, WorkflowEvent } from '@deepkit/workflow'; +import { EventDispatcher } from '@deepkit/event'; + +class OrderEvent extends WorkflowEvent { + constructor(public orderId: string, public amount: number) { + super(); + } +} + +const orderWorkflow = createWorkflow('order', { + created: OrderEvent, + processing: WorkflowEvent, + paid: WorkflowEvent, + shipped: WorkflowEvent, + delivered: WorkflowEvent, + cancelled: WorkflowEvent, +}, { + created: ['processing', 'cancelled'], + processing: ['paid', 'cancelled'], + paid: ['shipped', 'cancelled'], + shipped: 'delivered', +}); + +test('workflow basic transitions', async () => { + const dispatcher = new EventDispatcher(); + const workflow = orderWorkflow.create('created', dispatcher); + + // Test initial state + expect(workflow.state.get()).toBe('created'); + expect(workflow.isDone()).toBe(false); + + // Test valid transitions + expect(workflow.can('processing')).toBe(true); + expect(workflow.can('cancelled')).toBe(true); + expect(workflow.can('paid')).toBe(false); + + // Apply transition + await workflow.apply('processing'); + expect(workflow.state.get()).toBe('processing'); + + // Test new valid transitions + expect(workflow.can('processing')).toBe(false); + expect(workflow.can('paid')).toBe(true); + expect(workflow.can('cancelled')).toBe(true); +}); + +test('workflow invalid transitions', async () => { + const dispatcher = new EventDispatcher(); + const workflow = orderWorkflow.create('created', dispatcher); + + // Test invalid transition + await expect(workflow.apply('paid')).rejects.toThrow( + 'Can not apply state change from created->paid' + ); + + // Test wrong event type + await expect(workflow.apply('processing', new OrderEvent('123', 100))).rejects.toThrow( + 'State processing got the wrong event. Expected WorkflowEvent, got OrderEvent' + ); +}); + +test('workflow completion', async () => { + const dispatcher = new EventDispatcher(); + const workflow = orderWorkflow.create('created', dispatcher); + + // Progress through workflow + await workflow.apply('processing'); + await workflow.apply('paid'); + await workflow.apply('shipped'); + await workflow.apply('delivered'); + + // Test final state + expect(workflow.state.get()).toBe('delivered'); + expect(workflow.isDone()).toBe(true); + expect(workflow.can('shipped')).toBe(false); +}); +``` + +### Testing Event Listeners + +Test workflow event listeners and business logic: + +```typescript +import { eventDispatcher } from '@deepkit/event'; + +class OrderService { + public processedOrders: string[] = []; + public sentEmails: Array<{to: string, subject: string}> = []; + + @eventDispatcher.listen(orderWorkflow.onCreated) + async onOrderCreated(event: typeof orderWorkflow.onCreated.event) { + this.processedOrders.push(event.orderId); + this.sentEmails.push({ + to: 'customer@example.com', + subject: `Order ${event.orderId} created` + }); + } + + @eventDispatcher.listen(orderWorkflow.onPaid) + async onOrderPaid(event: typeof orderWorkflow.onPaid.event) { + this.sentEmails.push({ + to: 'customer@example.com', + subject: `Payment confirmed for order ${event.orderId}` + }); + } +} + +test('workflow event listeners', async () => { + const dispatcher = new EventDispatcher(); + const orderService = new OrderService(); + + // Register listeners manually for testing + dispatcher.listen(orderWorkflow.onCreated, (event) => orderService.onOrderCreated(event)); + dispatcher.listen(orderWorkflow.onPaid, (event) => orderService.onOrderPaid(event)); + + const workflow = orderWorkflow.create('created', dispatcher); + + // Apply transitions and verify listener behavior + await workflow.apply('created', new OrderEvent('order-123', 99.99)); + + expect(orderService.processedOrders).toContain('order-123'); + expect(orderService.sentEmails).toHaveLength(1); + expect(orderService.sentEmails[0].subject).toBe('Order order-123 created'); + + await workflow.apply('processing'); + await workflow.apply('paid'); + + expect(orderService.sentEmails).toHaveLength(2); + expect(orderService.sentEmails[1].subject).toBe('Payment confirmed for order order-123'); +}); +``` + +## Testing with Dependency Injection + +### Mock Services + +Create testable workflows with mocked dependencies: + +```typescript +import { InjectorContext, InjectorModule } from '@deepkit/injector'; + +interface EmailService { + sendEmail(to: string, subject: string, body: string): Promise; +} + +interface PaymentService { + processPayment(amount: number): Promise<{success: boolean, transactionId?: string}>; +} + +class MockEmailService implements EmailService { + public sentEmails: Array<{to: string, subject: string, body: string}> = []; + + async sendEmail(to: string, subject: string, body: string): Promise { + this.sentEmails.push({ to, subject, body }); + } +} + +class MockPaymentService implements PaymentService { + public shouldSucceed = true; + public processedPayments: number[] = []; + + async processPayment(amount: number): Promise<{success: boolean, transactionId?: string}> { + this.processedPayments.push(amount); + return this.shouldSucceed + ? { success: true, transactionId: 'mock_txn_123' } + : { success: false }; + } +} + +class TestableOrderService { + constructor( + private emailService: EmailService, + private paymentService: PaymentService + ) {} + + @eventDispatcher.listen(orderWorkflow.onCreated) + async onOrderCreated(event: typeof orderWorkflow.onCreated.event) { + await this.emailService.sendEmail( + 'customer@example.com', + 'Order Created', + `Your order ${event.orderId} has been created.` + ); + } + + @eventDispatcher.listen(orderWorkflow.onProcessing) + async onOrderProcessing(event: typeof orderWorkflow.onProcessing.event) { + const result = await this.paymentService.processPayment(event.amount); + if (result.success) { + event.next('paid'); + } else { + event.next('cancelled'); + } + } +} + +test('workflow with mocked dependencies', async () => { + const mockEmailService = new MockEmailService(); + const mockPaymentService = new MockPaymentService(); + + const module = new InjectorModule([ + { provide: 'EmailService', useValue: mockEmailService }, + { provide: 'PaymentService', useValue: mockPaymentService }, + TestableOrderService, + ]); + + const injector = new InjectorContext(module); + const dispatcher = new EventDispatcher(injector); + + // Register the service + dispatcher.registerListener(TestableOrderService, module); + + const workflow = orderWorkflow.create('created', dispatcher); + + // Test successful payment flow + await workflow.apply('created', new OrderEvent('order-123', 99.99)); + + expect(mockEmailService.sentEmails).toHaveLength(1); + expect(mockEmailService.sentEmails[0].subject).toBe('Order Created'); + + await workflow.apply('processing'); + + expect(mockPaymentService.processedPayments).toContain(99.99); + expect(workflow.state.get()).toBe('paid'); + + // Test failed payment flow + mockPaymentService.shouldSucceed = false; + const failedWorkflow = orderWorkflow.create('created', dispatcher); + + await failedWorkflow.apply('created', new OrderEvent('order-456', 199.99)); + await failedWorkflow.apply('processing'); + + expect(failedWorkflow.state.get()).toBe('cancelled'); +}); +``` + +### Testing with Framework + +Use Deepkit Framework's testing utilities: + +```typescript +import { createTestingApp } from '@deepkit/framework'; + +test('workflow integration test', async () => { + const testing = createTestingApp({ + listeners: [TestableOrderService], + providers: [ + { provide: 'EmailService', useClass: MockEmailService }, + { provide: 'PaymentService', useClass: MockPaymentService }, + ], + }); + + const dispatcher = testing.app.get(EventDispatcher); + const emailService = testing.app.get('EmailService') as MockEmailService; + const paymentService = testing.app.get('PaymentService') as MockPaymentService; + + const workflow = orderWorkflow.create('created', dispatcher); + + await workflow.apply('created', new OrderEvent('test-order', 150.00)); + await workflow.apply('processing'); + + expect(emailService.sentEmails).toHaveLength(1); + expect(paymentService.processedPayments).toContain(150.00); + expect(workflow.state.get()).toBe('paid'); +}); +``` + +## Advanced Testing Patterns + +### Testing Automatic State Progression + +Test workflows that automatically progress through multiple states: + +```typescript +const autoProgressWorkflow = createWorkflow('autoProgress', { + start: WorkflowEvent, + step1: WorkflowEvent, + step2: WorkflowEvent, + step3: WorkflowEvent, + completed: WorkflowEvent, +}, { + start: 'step1', + step1: 'step2', + step2: 'step3', + step3: 'completed', +}); + +class AutoProgressService { + @eventDispatcher.listen(autoProgressWorkflow.onStart) + async onStart(event: typeof autoProgressWorkflow.onStart.event) { + event.next('step1'); + } + + @eventDispatcher.listen(autoProgressWorkflow.onStep1) + async onStep1(event: typeof autoProgressWorkflow.onStep1.event) { + event.next('step2'); + } + + @eventDispatcher.listen(autoProgressWorkflow.onStep2) + async onStep2(event: typeof autoProgressWorkflow.onStep2.event) { + event.next('step3'); + } + + @eventDispatcher.listen(autoProgressWorkflow.onStep3) + async onStep3(event: typeof autoProgressWorkflow.onStep3.event) { + event.next('completed'); + } +} + +test('automatic state progression', async () => { + const dispatcher = new EventDispatcher(); + const service = new AutoProgressService(); + + // Register all listeners + dispatcher.listen(autoProgressWorkflow.onStart, (e) => service.onStart(e)); + dispatcher.listen(autoProgressWorkflow.onStep1, (e) => service.onStep1(e)); + dispatcher.listen(autoProgressWorkflow.onStep2, (e) => service.onStep2(e)); + dispatcher.listen(autoProgressWorkflow.onStep3, (e) => service.onStep3(e)); + + const workflow = autoProgressWorkflow.create('start', dispatcher); + + // Single apply should progress through all states + await workflow.apply('start'); + + expect(workflow.state.get()).toBe('completed'); + expect(workflow.isDone()).toBe(true); +}); +``` + +### Testing Error Conditions + +Test error handling and recovery: + +```typescript +class ErrorProneService { + public shouldFail = false; + public failureCount = 0; + + @eventDispatcher.listen(orderWorkflow.onProcessing) + async onProcessing(event: typeof orderWorkflow.onProcessing.event) { + if (this.shouldFail) { + this.failureCount++; + throw new Error('Processing failed'); + } + event.next('paid'); + } +} + +test('workflow error handling', async () => { + const dispatcher = new EventDispatcher(); + const service = new ErrorProneService(); + + dispatcher.listen(orderWorkflow.onProcessing, (e) => service.onProcessing(e)); + + const workflow = orderWorkflow.create('created', dispatcher); + + // Test successful processing + await workflow.apply('created', new OrderEvent('order-123', 99.99)); + await workflow.apply('processing'); + expect(workflow.state.get()).toBe('paid'); + + // Test error condition + service.shouldFail = true; + const errorWorkflow = orderWorkflow.create('created', dispatcher); + + await errorWorkflow.apply('created', new OrderEvent('order-456', 199.99)); + + await expect(errorWorkflow.apply('processing')).rejects.toThrow('Processing failed'); + expect(service.failureCount).toBe(1); + expect(errorWorkflow.state.get()).toBe('processing'); // State unchanged on error +}); +``` + +### Testing Conditional Logic + +Test workflows with complex conditional logic: + +```typescript +class ConditionalOrderService { + @eventDispatcher.listen(orderWorkflow.onCreated) + async onOrderCreated(event: typeof orderWorkflow.onCreated.event) { + if (event.amount < 50) { + // Small orders auto-approve + event.next('paid'); + } else if (event.amount > 1000) { + // Large orders need manual review + event.next('cancelled'); // Simulate manual review rejection + } else { + // Normal orders go through processing + event.next('processing'); + } + } +} + +test('conditional workflow logic', async () => { + const dispatcher = new EventDispatcher(); + const service = new ConditionalOrderService(); + + dispatcher.listen(orderWorkflow.onCreated, (e) => service.onOrderCreated(e)); + + // Test small order (auto-approve) + const smallOrderWorkflow = orderWorkflow.create('created', dispatcher); + await smallOrderWorkflow.apply('created', new OrderEvent('small-order', 25.00)); + expect(smallOrderWorkflow.state.get()).toBe('paid'); + + // Test normal order (processing) + const normalOrderWorkflow = orderWorkflow.create('created', dispatcher); + await normalOrderWorkflow.apply('created', new OrderEvent('normal-order', 100.00)); + expect(normalOrderWorkflow.state.get()).toBe('processing'); + + // Test large order (rejected) + const largeOrderWorkflow = orderWorkflow.create('created', dispatcher); + await largeOrderWorkflow.apply('created', new OrderEvent('large-order', 1500.00)); + expect(largeOrderWorkflow.state.get()).toBe('cancelled'); +}); +``` + +## Performance Testing + +### Load Testing Workflows + +Test workflow performance under load: + +```typescript +test('workflow performance under load', async () => { + const dispatcher = new EventDispatcher(); + const workflows: Array = []; + + const startTime = Date.now(); + + // Create and process 1000 workflows + for (let i = 0; i < 1000; i++) { + const workflow = orderWorkflow.create('created', dispatcher); + workflows.push(workflow); + + await workflow.apply('created', new OrderEvent(`order-${i}`, 99.99)); + await workflow.apply('processing'); + await workflow.apply('paid'); + } + + const endTime = Date.now(); + const duration = endTime - startTime; + + console.log(`Processed 1000 workflows in ${duration}ms`); + expect(duration).toBeLessThan(5000); // Should complete within 5 seconds + + // Verify all workflows completed + expect(workflows.every(w => w.state.get() === 'paid')).toBe(true); +}); +``` + +### Memory Usage Testing + +Test for memory leaks in long-running workflows: + +```typescript +test('workflow memory usage', async () => { + const dispatcher = new EventDispatcher(); + const initialMemory = process.memoryUsage().heapUsed; + + // Create and dispose many workflows + for (let i = 0; i < 10000; i++) { + const workflow = orderWorkflow.create('created', dispatcher); + await workflow.apply('created', new OrderEvent(`order-${i}`, 99.99)); + // Workflow should be garbage collected after this scope + } + + // Force garbage collection if available + if (global.gc) { + global.gc(); + } + + const finalMemory = process.memoryUsage().heapUsed; + const memoryIncrease = finalMemory - initialMemory; + + console.log(`Memory increase: ${memoryIncrease / 1024 / 1024}MB`); + expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024); // Less than 50MB increase +}); +``` + +## Integration Testing + +### End-to-End Workflow Testing + +Test complete workflow scenarios: + +```typescript +import { http } from '@deepkit/http'; + +@http.controller() +class OrderController { + constructor(private dispatcher: EventDispatcher) {} + + @http.POST('/orders') + async createOrder(@http.body() data: {customerId: string, amount: number}) { + const orderId = `order_${Date.now()}`; + const workflow = orderWorkflow.create('created', this.dispatcher); + + await workflow.apply('created', new OrderEvent(orderId, data.amount)); + + return { orderId, status: workflow.state.get() }; + } +} + +test('end-to-end order workflow', async () => { + const testing = createTestingApp({ + controllers: [OrderController], + listeners: [TestableOrderService], + providers: [ + { provide: 'EmailService', useClass: MockEmailService }, + { provide: 'PaymentService', useClass: MockPaymentService }, + ], + }); + + const httpKernel = testing.app.get(HttpKernel); + + // Create order via HTTP + const response = await httpKernel.request(HttpRequest.POST('/orders').json({ + customerId: 'customer-123', + amount: 99.99 + })); + + expect(response.statusCode).toBe(200); + + const result = response.json; + expect(result.orderId).toBeDefined(); + expect(result.status).toBe('created'); + + // Verify services were called + const emailService = testing.app.get('EmailService') as MockEmailService; + expect(emailService.sentEmails).toHaveLength(1); +}); +``` + +## Best Practices + +1. **Test State Transitions**: Verify all valid and invalid transitions +2. **Mock External Dependencies**: Use mocks for services to isolate workflow logic +3. **Test Error Conditions**: Ensure workflows handle errors gracefully +4. **Verify Event Listeners**: Test that listeners are called with correct data +5. **Test Automatic Progression**: Verify multi-step automatic state changes +6. **Performance Testing**: Test workflows under load and check for memory leaks +7. **Integration Testing**: Test complete scenarios from start to finish +8. **Use Descriptive Test Names**: Make test intentions clear +9. **Test Edge Cases**: Cover boundary conditions and unusual scenarios +10. **Maintain Test Data**: Use factories or builders for consistent test data + +## Next Steps + +- [Advanced Features](./advanced-features.md) - Performance optimization and debugging +- [State Management](./state-management.md) - Complex state patterns +- [Dependency Injection](./dependency-injection.md) - DI patterns for testable workflows +- [Events](./events.md) - Advanced event handling patterns From a36d55ef1f1069dd42872458e1294436cb765f7c Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 21:16:25 +0200 Subject: [PATCH 08/15] docs: improve orm --- .../pages/documentation/orm/aggregation.md | 224 ++++++++++++ website/src/pages/documentation/orm/entity.md | 242 +++++++++++- website/src/pages/documentation/orm/events.md | 324 +++++++++++++++++ .../documentation/orm/getting-started.md | 146 ++++++++ .../pages/documentation/orm/performance.md | 342 +++++++++++++++++ website/src/pages/documentation/orm/query.md | 122 ++++++- .../src/pages/documentation/orm/raw-access.md | 144 +++++++- .../src/pages/documentation/orm/session.md | 210 +++++++++++ .../src/pages/documentation/orm/testing.md | 344 ++++++++++++++++++ 9 files changed, 2077 insertions(+), 21 deletions(-) create mode 100644 website/src/pages/documentation/orm/aggregation.md create mode 100644 website/src/pages/documentation/orm/performance.md create mode 100644 website/src/pages/documentation/orm/testing.md diff --git a/website/src/pages/documentation/orm/aggregation.md b/website/src/pages/documentation/orm/aggregation.md new file mode 100644 index 000000000..dbf28f051 --- /dev/null +++ b/website/src/pages/documentation/orm/aggregation.md @@ -0,0 +1,224 @@ +# Aggregation + +Deepkit ORM provides powerful aggregation capabilities that allow you to perform calculations and data analysis directly in the database. This is essential for building analytics, reports, and dashboards efficiently. + +## Overview + +Aggregation functions work by grouping data and performing calculations on each group. You can use aggregation with or without explicit grouping - without grouping, the entire result set is treated as one group. + +```typescript +@entity.name('order') +class Order { + id: number & PrimaryKey & AutoIncrement = 0; + customerId: number = 0; + amount: number = 0; + status: 'pending' | 'completed' | 'cancelled' = 'pending'; + createdAt: Date = new Date(); +} + +@entity.name('product') +class Product { + id: number & PrimaryKey & AutoIncrement = 0; + category: string = ''; + name: string = ''; + price: number = 0; + stock: number = 0; +} +``` + +## Aggregation Functions + +### withSum() +Calculates the sum of numeric values: + +```typescript +// Total revenue from all orders +const totalRevenue = await database.query(Order) + .withSum('amount') + .find(); +// Result: [{ amount: 125430.50 }] + +// Revenue per customer +const customerRevenue = await database.query(Order) + .groupBy('customerId') + .withSum('amount', 'totalSpent') + .find(); +// Result: [ +// { customerId: 1, totalSpent: 1250.00 }, +// { customerId: 2, totalSpent: 890.50 } +// ] +``` + +### withCount() +Counts the number of records: + +```typescript +// Total number of orders +const orderCount = await database.query(Order) + .withCount('id', 'totalOrders') + .find(); +// Result: [{ totalOrders: 1543 }] + +// Orders per status +const statusCounts = await database.query(Order) + .groupBy('status') + .withCount('id', 'count') + .find(); +// Result: [ +// { status: 'pending', count: 45 }, +// { status: 'completed', count: 1480 }, +// { status: 'cancelled', count: 18 } +// ] +``` + +### withAverage() +Calculates the average value: + +```typescript +// Average order amount +const avgOrderAmount = await database.query(Order) + .withAverage('amount', 'averageOrder') + .find(); +// Result: [{ averageOrder: 81.25 }] + +// Average price per product category +const avgPriceByCategory = await database.query(Product) + .groupBy('category') + .withAverage('price', 'avgPrice') + .orderBy('category') + .find(); +``` + +### withMin() and withMax() +Find minimum and maximum values: + +```typescript +// Price range analysis +const priceRange = await database.query(Product) + .groupBy('category') + .withMin('price', 'minPrice') + .withMax('price', 'maxPrice') + .withAverage('price', 'avgPrice') + .orderBy('category') + .find(); +// Result: [ +// { category: 'electronics', minPrice: 9.99, maxPrice: 1999.99, avgPrice: 245.50 }, +// { category: 'books', minPrice: 5.99, maxPrice: 89.99, avgPrice: 24.50 } +// ] +``` + +### withGroupConcat() +Concatenates values within each group: + +```typescript +// Get all product names per category +const productsByCategory = await database.query(Product) + .groupBy('category') + .withGroupConcat('name', 'products') + .find(); + +// Note: Output format varies by database adapter +// SQLite: { category: 'books', products: 'Book1,Book2,Book3' } +// MongoDB: { category: 'books', products: ['Book1', 'Book2', 'Book3'] } +// MySQL/PostgreSQL: { category: 'books', products: 'Book1,Book2,Book3' } +``` + +## Advanced Aggregation Patterns + +### Multiple Aggregations +Combine multiple aggregation functions in a single query: + +```typescript +const comprehensiveStats = await database.query(Order) + .groupBy('status') + .withCount('id', 'orderCount') + .withSum('amount', 'totalRevenue') + .withAverage('amount', 'avgOrderValue') + .withMin('amount', 'smallestOrder') + .withMax('amount', 'largestOrder') + .orderBy('status') + .find(); +``` + +### Conditional Aggregation +Use filters to create conditional aggregations: + +```typescript +// Revenue from completed orders only +const completedRevenue = await database.query(Order) + .filter({ status: 'completed' }) + .withSum('amount', 'completedRevenue') + .find(); + +// High-value orders per customer +const highValueCustomers = await database.query(Order) + .filter({ amount: { $gte: 100 } }) + .groupBy('customerId') + .withCount('id', 'highValueOrders') + .withSum('amount', 'highValueRevenue') + .filter({ highValueRevenue: { $gte: 1000 } }) + .find(); +``` + +### Time-based Aggregation +Aggregate data by time periods: + +```typescript +// Note: Exact syntax may vary by database adapter +// Monthly revenue (example for SQL databases) +const monthlyRevenue = await database.query(Order) + .filter({ status: 'completed' }) + .groupBy('YEAR(createdAt)', 'MONTH(createdAt)') + .withSum('amount', 'revenue') + .withCount('id', 'orderCount') + .orderBy('YEAR(createdAt)', 'MONTH(createdAt)') + .find(); +``` + +## Performance Considerations + +### Indexing for Aggregation +Ensure proper indexing for fields used in: +- `groupBy()` clauses +- `filter()` conditions +- Aggregated fields + +```typescript +// Example: Index on frequently grouped/filtered fields +@entity.name('order') +class Order { + id: number & PrimaryKey & AutoIncrement = 0; + + customerId: number & Index = 0; + status: 'pending' | 'completed' | 'cancelled' & Index = 'pending'; + amount: number = 0; + createdAt: Date & Index = new Date(); +} +``` + +### Large Dataset Considerations +For large datasets, consider: +- Using `limit()` with aggregation when appropriate +- Implementing pagination for aggregated results +- Using database-specific optimization features + +```typescript +// Top 10 customers by revenue +const topCustomers = await database.query(Order) + .groupBy('customerId') + .withSum('amount', 'totalRevenue') + .orderBy('totalRevenue', 'desc') + .limit(10) + .find(); +``` + +## Database Adapter Differences + +Different database adapters may have slight variations in aggregation behavior: + +- **SQLite**: Group concatenation returns comma-separated strings +- **MongoDB**: Group concatenation returns arrays +- **MySQL/PostgreSQL**: Support more advanced date/time grouping functions +- **Memory Database**: Full aggregation support for testing + +Always test aggregation queries with your specific database adapter to ensure expected behavior. diff --git a/website/src/pages/documentation/orm/entity.md b/website/src/pages/documentation/orm/entity.md index 8470de5e0..66427aa2e 100644 --- a/website/src/pages/documentation/orm/entity.md +++ b/website/src/pages/documentation/orm/entity.md @@ -217,10 +217,244 @@ interface User { ## Default Values -## Default Expressions +## Database Field Options -## Complex Types +The `DatabaseField` decorator provides fine-grained control over how fields are handled in the database: -## Exclude +```typescript +import { DatabaseField, entity, PrimaryKey, AutoIncrement } from '@deepkit/type'; + +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + + // Skip field during inserts (useful for computed columns) + username: string & DatabaseField<{ skip: true }> = ''; + + // Custom column name + email: string & DatabaseField<{ name: 'email_address' }> = ''; + + // Skip during updates only + createdAt: Date & DatabaseField<{ skipUpdate: true }> = new Date(); + + // Skip during inserts only + updatedAt: Date & DatabaseField<{ skipInsert: true }> = new Date(); + + constructor(username: string, email: string) { + this.username = username; + this.email = email; + } +} +``` + +## Embedded Objects + +Deepkit ORM supports embedded objects for complex data structures: + +```typescript +interface Address { + street: string; + city: string; + zipCode: string; + country: string; +} + +interface ContactInfo { + phone?: string; + website?: string; + socialMedia: { + twitter?: string; + linkedin?: string; + }; +} + +@entity.name('company') +class Company { + id: number & PrimaryKey & AutoIncrement = 0; + + // Embedded object + address: Address = { + street: '', + city: '', + zipCode: '', + country: '' + }; + + // Nested embedded objects + contact: ContactInfo = { + socialMedia: {} + }; + + constructor(public name: string) {} +} + +// Usage +const company = new Company('Tech Corp'); +company.address = { + street: '123 Main St', + city: 'San Francisco', + zipCode: '94105', + country: 'USA' +}; + +company.contact = { + phone: '+1-555-0123', + website: 'https://techcorp.com', + socialMedia: { + twitter: '@techcorp', + linkedin: 'company/techcorp' + } +}; + +await database.persist(company); + +// Query embedded fields +const companies = await database.query(Company) + .filter({ 'address.city': 'San Francisco' }) + .find(); +``` + +## Advanced Validation + +Combine multiple validation constraints for robust data integrity: + +```typescript +import { + entity, PrimaryKey, AutoIncrement, + MinLength, MaxLength, Pattern, Positive, + validate, ValidatorError +} from '@deepkit/type'; + +// Custom validator function +function ValidAge(min: number = 0, max: number = 150) { + return (value: number): ValidatorError | void => { + if (value < min || value > max) { + return new ValidatorError('age', `Age must be between ${min} and ${max}`); + } + }; +} + +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + + // Multiple string constraints + username: string & MinLength<3> & MaxLength<20> & Pattern<'^[a-zA-Z0-9_]+$'> = ''; -## Database Specific Column Types + // Email validation + email: string & Pattern<'^[^@]+@[^@]+\.[^@]+$'> = ''; + + // Custom validation + @t.validate(ValidAge(13, 120)) + age: number & Positive = 0; + + // Optional field with validation + website?: string & Pattern<'^https?://.*'>; + + constructor(username: string, email: string, age: number) { + this.username = username; + this.email = email; + this.age = age; + } +} + +// Validation is automatically applied during persist operations +try { + const user = new User('ab', 'invalid-email', -5); // Invalid data + await database.persist(user); +} catch (error) { + console.log('Validation errors:', error.message); +} +``` + +## Complex Types and Arrays + +Handle arrays and complex data types: + +```typescript +@entity.name('blog_post') +class BlogPost { + id: number & PrimaryKey & AutoIncrement = 0; + + // Array of strings + tags: string[] = []; + + // Array of numbers + ratings: number[] = []; + + // Array of embedded objects + comments: Array<{ + author: string; + content: string; + createdAt: Date; + likes: number; + }> = []; + + // JSON field for flexible data + metadata: Record = {}; + + // Binary data + thumbnail?: Uint8Array; + + constructor( + public title: string, + public content: string, + public authorId: number + ) {} +} + +// Usage +const post = new BlogPost('My First Post', 'Hello World!', 1); +post.tags = ['javascript', 'typescript', 'deepkit']; +post.comments = [ + { + author: 'John', + content: 'Great post!', + createdAt: new Date(), + likes: 5 + } +]; +post.metadata = { + featured: true, + category: 'tutorial', + readTime: 5 +}; + +await database.persist(post); + +// Query array fields +const jsPosts = await database.query(BlogPost) + .filter({ tags: { $in: ['javascript'] } }) + .find(); +``` + +## Exclude Fields + +Exclude sensitive or computed fields from database operations: + +```typescript +import { Exclude } from '@deepkit/type'; + +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + username: string = ''; + email: string = ''; + + // Excluded from database operations + password: string & Exclude<'database'> = ''; + + // Computed field - not stored in database + get displayName(): string & Exclude<'database'> { + return `${this.firstName} ${this.lastName}`; + } + + // Temporary field for business logic + isNewUser: boolean & Exclude<'database'> = false; + + constructor(username: string, email: string) { + this.username = username; + this.email = email; + } +} +``` diff --git a/website/src/pages/documentation/orm/events.md b/website/src/pages/documentation/orm/events.md index 066c313d8..e17502e01 100644 --- a/website/src/pages/documentation/orm/events.md +++ b/website/src/pages/documentation/orm/events.md @@ -49,6 +49,330 @@ unsubscribe(); | Query.onFetch | When objects where fetched via find()/findOne()/etc | | Query.onDeletePre | Before objects are deleted via deleteMany/deleteOne() | | Query.onDeletePost | After objects are deleted via deleteMany/deleteOne() | +| Query.onPatchPre | Before objects are updated via patchOne/patchMany() | +| Query.onPatchPost | After objects are updated via patchOne/patchMany() | + +## Unit of Work Events + +Unit of Work events are triggered during session operations and provide hooks into the persistence lifecycle: + +```typescript +import { DatabaseSession } from '@deepkit/orm'; + +const database = new Database(...); + +// Listen to session events +database.listen(DatabaseSession.onInsertPre, async (event) => { + console.log('About to insert:', event.items.length, 'items'); + + // Modify items before insertion + for (const item of event.items) { + if (item instanceof User) { + item.createdAt = new Date(); + } + } +}); + +database.listen(DatabaseSession.onUpdatePre, async (event) => { + console.log('About to update:', event.changeSets.length, 'items'); + + // Add automatic timestamp updates + for (const changeSet of event.changeSets) { + if (changeSet.item instanceof User) { + changeSet.changes.set('updatedAt', new Date()); + } + } +}); +``` + +### Available Unit of Work Events: + +| Event-Token | Description | +|--------------------------------|------------------------------------------------| +| DatabaseSession.onInsertPre | Before entities are inserted | +| DatabaseSession.onInsertPost | After entities are inserted | +| DatabaseSession.onUpdatePre | Before entities are updated | +| DatabaseSession.onUpdatePost | After entities are updated | +| DatabaseSession.onDeletePre | Before entities are deleted | +| DatabaseSession.onDeletePost | After entities are deleted | + +## Building Plugins + +Events are the foundation for building powerful plugins. Here's how to create reusable plugins: + +### Timestamp Plugin + +```typescript +import { Database, DatabaseSession } from '@deepkit/orm'; +import { t, Data } from '@deepkit/type'; + +// Decorator to mark timestamp fields +function timestamp(type: 'created' | 'updated' | 'both' = 'both') { + return t.data('timestamp', type); +} + +class TimestampPlugin { + static register(database: Database) { + // Handle creation timestamps + database.listen(DatabaseSession.onInsertPre, (event) => { + for (const item of event.items) { + const schema = event.classSchema; + for (const property of schema.getProperties()) { + const timestampType = property.getData()['timestamp']; + if (timestampType === 'created' || timestampType === 'both') { + (item as any)[property.name] = new Date(); + } + } + } + }); + + // Handle update timestamps + database.listen(DatabaseSession.onUpdatePre, (event) => { + for (const changeSet of event.changeSets) { + const schema = event.classSchema; + for (const property of schema.getProperties()) { + const timestampType = property.getData()['timestamp']; + if (timestampType === 'updated' || timestampType === 'both') { + changeSet.changes.set(property.name, new Date()); + } + } + } + }); + } +} + +// Usage with decorator +@entity.name('post') +class Post { + id: number & PrimaryKey & AutoIncrement = 0; + + @timestamp('created') + createdAt: Date = new Date(); + + @timestamp('updated') + updatedAt: Date = new Date(); + + constructor(public title: string, public content: string) {} +} + +// Alternative usage with type annotations +@entity.name('article') +class Article { + id: number & PrimaryKey & AutoIncrement = 0; + + // Using Data type annotation directly + createdAt: Date & Data<'timestamp', 'created'> = new Date(); + updatedAt: Date & Data<'timestamp', 'updated'> = new Date(); + + constructor(public title: string, public content: string) {} +} + +const database = new Database(...); +TimestampPlugin.register(database); +``` + +### Audit Trail Plugin + +Create an audit trail that tracks all changes to entities: + +```typescript +@entity.name('audit_log') +class AuditLog { + id: number & PrimaryKey & AutoIncrement = 0; + entityType: string = ''; + entityId: string = ''; + operation: 'INSERT' | 'UPDATE' | 'DELETE' = 'INSERT'; + changes: Record = {}; + userId?: number; + timestamp: Date = new Date(); +} + +class AuditPlugin { + constructor(private getCurrentUserId: () => number | undefined) {} + + register(database: Database) { + // Track inserts + database.listen(DatabaseSession.onInsertPost, async (event) => { + const auditLogs = event.items.map(item => new AuditLog()); + + for (let i = 0; i < event.items.length; i++) { + const item = event.items[i]; + const log = auditLogs[i]; + + log.entityType = event.classSchema.name; + log.entityId = String((item as any).id); + log.operation = 'INSERT'; + log.changes = { ...item }; + log.userId = this.getCurrentUserId(); + } + + await database.persist(...auditLogs); + }); + + // Track updates + database.listen(DatabaseSession.onUpdatePost, async (event) => { + const auditLogs = event.changeSets.map(() => new AuditLog()); + + for (let i = 0; i < event.changeSets.length; i++) { + const changeSet = event.changeSets[i]; + const log = auditLogs[i]; + + log.entityType = event.classSchema.name || 'unknown'; + log.entityId = String(changeSet.primaryKey); + log.operation = 'UPDATE'; + log.changes = Object.fromEntries(changeSet.changes); + log.userId = this.getCurrentUserId(); + } + + await database.persist(...auditLogs); + }); + + // Track deletes + database.listen(DatabaseSession.onDeletePost, async (event) => { + const auditLogs = event.items.map(() => new AuditLog()); + + for (let i = 0; i < event.items.length; i++) { + const item = event.items[i]; + const log = auditLogs[i]; + + log.entityType = event.classSchema.name; + log.entityId = String((item as any).id); + log.operation = 'DELETE'; + log.changes = { ...item }; + log.userId = this.getCurrentUserId(); + } + + await database.persist(...auditLogs); + }); + } +} + +// Usage +const auditPlugin = new AuditPlugin(() => getCurrentUser()?.id); +auditPlugin.register(database); +``` + +### Validation Plugin + +Add custom validation logic that runs before database operations: + +```typescript +class ValidationPlugin { + static register(database: Database) { + database.listen(DatabaseSession.onInsertPre, async (event) => { + for (const item of event.items) { + await this.validateEntity(item, event.classSchema); + } + }); + + database.listen(DatabaseSession.onUpdatePre, async (event) => { + for (const changeSet of event.changeSets) { + // Create a temporary object with applied changes for validation + const tempItem = { ...changeSet.item }; + for (const [key, value] of changeSet.changes) { + (tempItem as any)[key] = value; + } + await this.validateEntity(tempItem, event.classSchema); + } + }); + } + + private static async validateEntity(item: any, schema: ReflectionClass) { + // Custom business logic validation + if (item instanceof User) { + // Check if email is already taken + const existingUser = await database.query(User) + .filter({ email: item.email }) + .filter({ id: { $ne: item.id } }) + .findOneOrUndefined(); + + if (existingUser) { + throw new Error(`Email ${item.email} is already taken`); + } + + // Validate age requirements + if (item.age < 13) { + throw new Error('Users must be at least 13 years old'); + } + } + } +} + +ValidationPlugin.register(database); +``` + +## Error Handling in Events + +Handle errors gracefully in event listeners: + +```typescript +import { onDatabaseError } from '@deepkit/orm'; + +// Global error handler +database.listen(onDatabaseError, (event) => { + console.error('Database error:', event.error); + + if (event instanceof DatabaseErrorInsertEvent) { + console.error('Failed to insert:', event.items); + } else if (event instanceof DatabaseErrorUpdateEvent) { + console.error('Failed to update:', event.changeSets); + } + + // Log to external service + logToExternalService(event.error, event); +}); + +// Specific error handling in plugins +class RobustPlugin { + static register(database: Database) { + database.listen(DatabaseSession.onInsertPre, async (event) => { + try { + // Plugin logic here + await this.processItems(event.items); + } catch (error) { + console.error('Plugin error during insert:', error); + // Decide whether to throw (abort operation) or continue + // throw error; // Abort the operation + // or log and continue + } + }); + } +} +``` + +## Performance Considerations + +When working with events, consider performance implications: + +```typescript +class PerformantPlugin { + static register(database: Database) { + // Batch operations when possible + database.listen(DatabaseSession.onInsertPost, async (event) => { + if (event.items.length > 100) { + // Process in batches for large operations + const batchSize = 50; + for (let i = 0; i < event.items.length; i += batchSize) { + const batch = event.items.slice(i, i + batchSize); + await this.processBatch(batch); + } + } else { + await this.processItems(event.items); + } + }); + + // Use async operations carefully + database.listen(DatabaseSession.onUpdatePre, async (event) => { + // Avoid blocking the main operation with slow async calls + // Consider using background jobs for heavy processing + setImmediate(async () => { + await this.heavyProcessing(event.changeSets); + }); + }); + } +} +``` +| Query.onDeletePost | After objects are deleted via deleteMany/deleteOne() | | Query.onPatchPre | Before objects are patched/updated via patchMany/patchOne() | | Query.onPatchPost | After objects are patched/updated via patchMany/patchOne() | diff --git a/website/src/pages/documentation/orm/getting-started.md b/website/src/pages/documentation/orm/getting-started.md index 7b905be29..f211e9582 100644 --- a/website/src/pages/documentation/orm/getting-started.md +++ b/website/src/pages/documentation/orm/getting-started.md @@ -190,5 +190,151 @@ main(); ### MongoDB +```bash +npm install @deepkit/mongo +``` + +```typescript +import { MongoDatabaseAdapter } from '@deepkit/mongo'; + +const database = new Database( + new MongoDatabaseAdapter('mongodb://localhost:27017/myapp'), + [User] +); +``` + +## Testing Setup + +For testing, we recommend using `SQLiteDatabaseAdapter` with in-memory databases for the best balance of performance and SQL feature support: + +```typescript +import { SQLiteDatabaseAdapter } from '@deepkit/sqlite'; + +// In your test files +describe('User tests', () => { + let database: Database; + + beforeEach(async () => { + // Use SQLite in-memory database for testing + database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User]); + await database.migrate(); + }); + + afterEach(async () => { + await database.disconnect(); + }); + + test('should create user', async () => { + const user = new User('testuser', 'test@example.com'); + await database.persist(user); + + const found = await database.query(User).findOne(); + expect(found.username).toBe('testuser'); + }); +}); +``` + +> **Note**: While `MemoryDatabaseAdapter` is available for simple tests, `SQLiteDatabaseAdapter` with `:memory:` is recommended as it provides full SQL compatibility and better represents production behavior. + +## Quick Start Example + +Here's a complete example to get you started: + +```typescript +import { entity, PrimaryKey, AutoIncrement, MinLength } from '@deepkit/type'; +import { Database } from '@deepkit/orm'; +import { SQLiteDatabaseAdapter } from '@deepkit/sqlite'; + +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + created: Date = new Date(); + + constructor( + public username: string & MinLength<3>, + public email: string + ) {} +} + +@entity.name('post') +class Post { + id: number & PrimaryKey & AutoIncrement = 0; + created: Date = new Date(); + + constructor( + public author: User & Reference, + public title: string, + public content: string + ) {} +} + +async function main() { + // Setup database + const database = new Database( + new SQLiteDatabaseAdapter('./blog.sqlite'), + [User, Post] + ); + + // Create tables + await database.migrate(); + + // Create user + const user = new User('john_doe', 'john@example.com'); + await database.persist(user); + + // Create post + const post = new Post(user, 'My First Post', 'Hello, World!'); + await database.persist(post); + + // Query data + const posts = await database.query(Post) + .joinWith('author') + .find(); + + console.log('Posts:', posts); + + // Using session for multiple operations + const session = database.createSession(); + + const foundUser = await session.query(User).findOne(); + foundUser.username = 'john_updated'; + + const newPost = new Post(foundUser, 'Second Post', 'More content!'); + session.add(newPost); + + await session.commit(); // Saves both changes + + await database.disconnect(); +} + +main().catch(console.error); +``` + ## Plugins +Deepkit ORM supports a plugin system for extending functionality. Some built-in plugins include: + +### Soft Delete Plugin +```typescript +import { SoftDeletePlugin } from '@deepkit/orm'; + +const database = new Database(adapter, entities); +database.registerPlugin(new SoftDeletePlugin()); +``` + +### Log Plugin +```typescript +import { LogPlugin } from '@deepkit/orm'; + +const database = new Database(adapter, entities); +database.registerPlugin(new LogPlugin()); +``` + +## Next Steps + +- Learn about [Entities](entity.md) and how to define them +- Understand [Queries](query.md) and filtering +- Explore [Relations](relations.md) between entities +- Master [Sessions](session.md) for efficient operations +- Set up [Testing](testing.md) for your application + diff --git a/website/src/pages/documentation/orm/performance.md b/website/src/pages/documentation/orm/performance.md new file mode 100644 index 000000000..5d38d3263 --- /dev/null +++ b/website/src/pages/documentation/orm/performance.md @@ -0,0 +1,342 @@ +# Performance Best Practices + +Optimizing database performance is crucial for scalable applications. Deepkit ORM provides several features and patterns to help you build high-performance database operations. + +## Session vs Database Queries + +Understanding when to use sessions versus direct database queries is fundamental for performance: + +### Use Sessions When: +- Performing multiple related operations +- Need change tracking and automatic updates +- Working with entity relationships +- Implementing unit of work patterns + +```typescript +// Good: Use session for multiple operations +const session = database.createSession(); + +const user = await session.query(User).findOne(); +user.lastLogin = new Date(); + +const order = new Order(user.id, 99.99); +session.add(order); + +// Single commit for all changes +await session.commit(); +``` + +### Use Direct Database Queries When: +- Performing single operations +- Read-only queries +- Bulk operations +- Simple aggregations + +```typescript +// Good: Direct query for simple read operations +const users = await database.query(User) + .filter({ active: true }) + .limit(10) + .find(); + +// Good: Direct query for bulk operations +await database.query(User) + .filter({ lastLogin: { $lt: oldDate } }) + .patchMany({ active: false }); +``` + +## Identity Map and Change Detection + +The identity map prevents duplicate entity instances and enables automatic change detection: + +```typescript +const session = database.createSession(); + +// First query loads user into identity map +const user1 = await session.query(User).filter({ id: 1 }).findOne(); + +// Second query returns same instance from identity map (no database hit) +const user2 = await session.query(User).filter({ id: 1 }).findOne(); + +console.log(user1 === user2); // true + +// Changes are automatically tracked +user1.username = 'updated'; +await session.commit(); // Automatically generates UPDATE statement +``` + +### Identity Map Best Practices: +- Use sessions for related operations to benefit from identity map +- Be aware that sessions hold references to entities (memory usage) +- Create new sessions for different logical units of work +- Don't keep sessions alive too long in long-running processes + +## Batch Operations + +Batch operations are significantly more efficient than individual operations: + +```typescript +// Bad: Individual inserts +for (const userData of userDataArray) { + await database.persist(new User(userData.name, userData.email)); +} + +// Good: Batch insert +const users = userDataArray.map(data => new User(data.name, data.email)); +await database.persist(...users); + +// Good: Batch insert with session +const session = database.createSession(); +users.forEach(user => session.add(user)); +await session.commit(); +``` + +## Query Optimization + +### Select Only Required Fields +```typescript +// Bad: Select all fields +const users = await database.query(User).find(); + +// Good: Select only needed fields +const usernames = await database.query(User) + .select('username', 'email') + .find(); +``` + +### Use Proper Filtering +```typescript +// Bad: Load all data then filter in application +const allUsers = await database.query(User).find(); +const activeUsers = allUsers.filter(u => u.active); + +// Good: Filter in database +const activeUsers = await database.query(User) + .filter({ active: true }) + .find(); +``` + +### Limit Result Sets +```typescript +// Always use limit for potentially large result sets +const recentUsers = await database.query(User) + .orderBy('created', 'desc') + .limit(50) + .find(); + +// Use pagination for large datasets +const page = await database.query(User) + .orderBy('id') + .skip(page * pageSize) + .limit(pageSize) + .find(); +``` + +## Indexing Strategy + +Proper indexing is crucial for query performance: + +```typescript +import { Index, Unique } from '@deepkit/type'; + +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + + // Single field index + email: string & Index = ''; + + // Index with options + status: 'active' | 'inactive' & Index<{ name: 'status_idx' }> = 'active'; + + // Unique index (automatically creates index) + username: string & Unique = ''; + + created: Date = new Date(); +} + +// Compound indexes are defined at the entity level +@entity.name('order') + .index(['customerId', 'status']) // Compound index + .index(['createdAt'], { name: 'created_idx' }) // Named index +class Order { + id: number & PrimaryKey & AutoIncrement = 0; + customerId: number = 0; + status: 'pending' | 'completed' | 'cancelled' = 'pending'; + createdAt: Date = new Date(); +} +``` + +### Index Guidelines: +- Index fields used in WHERE clauses +- Index fields used in ORDER BY clauses +- Index foreign key fields +- Consider compound indexes for multi-field queries +- Don't over-index (impacts write performance) + +## Relationship Loading + +Control how relationships are loaded to optimize performance: + +```typescript +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + username: string = ''; + orders: Order[] & BackReference = []; +} + +@entity.name('order') +class Order { + id: number & PrimaryKey & AutoIncrement = 0; + user: User & Reference = new User(); + amount: number = 0; +} + +// Lazy loading (default) - loads relationships on access +const user = await database.query(User).findOne(); +const orders = await user.orders; // Separate query + +// Eager loading - loads relationships upfront +const usersWithOrders = await database.query(User) + .joinWith('orders') + .find(); + +// Select specific relationship fields +const usersWithOrderCounts = await database.query(User) + .joinWith('orders') + .select('username', 'orders.amount') + .find(); +``` + +## Connection Pooling + +For production applications, configure connection pooling: + +```typescript +import { PostgresDatabaseAdapter } from '@deepkit/postgres'; + +const database = new Database(new PostgresDatabaseAdapter({ + host: 'localhost', + database: 'myapp', + username: 'user', + password: 'password', + // Connection pool settings + connectionLimit: 10, + acquireTimeout: 60000, + timeout: 60000, +})); +``` + +## Monitoring and Debugging + +Use built-in tools to monitor performance: + +```typescript +import { Logger } from '@deepkit/logger'; + +// Enable query logging +const logger = new Logger(); +database.adapter.setLogger(logger); + +// Log slow queries +database.listen(Query.onFetch, (event) => { + const startTime = Date.now(); + + event.query.onExecuted.subscribe(() => { + const duration = Date.now() - startTime; + if (duration > 1000) { // Log queries taking > 1 second + logger.warning(`Slow query detected: ${duration}ms`); + } + }); +}); +``` + +## Memory Management + +Prevent memory leaks in long-running applications: + +```typescript +// Bad: Keeping session alive too long +const globalSession = database.createSession(); + +// Good: Create sessions per request/operation +async function handleRequest() { + const session = database.createSession(); + try { + // Perform operations + const result = await session.query(User).find(); + return result; + } finally { + // Session will be garbage collected + } +} + +// Good: Explicit cleanup for long-running processes +const session = database.createSession(); +try { + // Perform batch operations + await processBatchData(session); +} finally { + // Clear identity map to free memory + session.identityMap.clear(); +} +``` + +## Aggregation Performance + +Use database-level aggregation instead of application-level calculations: + +```typescript +// Bad: Load all data and calculate in application +const orders = await database.query(Order).find(); +const totalRevenue = orders.reduce((sum, order) => sum + order.amount, 0); + +// Good: Use database aggregation +const result = await database.query(Order) + .withSum('amount', 'totalRevenue') + .findOne(); +const totalRevenue = result.totalRevenue; + +// Good: Aggregation with grouping +const revenueByMonth = await database.query(Order) + .groupBy('YEAR(created)', 'MONTH(created)') + .withSum('amount', 'revenue') + .withCount('id', 'orderCount') + .find(); +``` + +## Performance Testing + +Include performance tests in your test suite: + +```typescript +describe('Performance tests', () => { + test('bulk insert performance', async () => { + const users = Array.from({ length: 10000 }, (_, i) => + new User(`user${i}`, `user${i}@example.com`) + ); + + const startTime = Date.now(); + await database.persist(...users); + const duration = Date.now() - startTime; + + expect(duration).toBeLessThan(5000); // Should complete in under 5 seconds + }); + + test('query performance with large dataset', async () => { + // Setup large dataset... + + const startTime = Date.now(); + const results = await database.query(User) + .filter({ active: true }) + .orderBy('created', 'desc') + .limit(100) + .find(); + const duration = Date.now() - startTime; + + expect(duration).toBeLessThan(1000); // Should complete in under 1 second + expect(results.length).toBeLessThanOrEqual(100); + }); +}); +``` diff --git a/website/src/pages/documentation/orm/query.md b/website/src/pages/documentation/orm/query.md index b0766ecff..6cf488384 100644 --- a/website/src/pages/documentation/orm/query.md +++ b/website/src/pages/documentation/orm/query.md @@ -3,7 +3,7 @@ A query is an object that describes how to retrieve or modify data from the database. It has several methods to describe the query and termination methods that execute them. The database adapter can extend the query API in many ways to support database specific features. -You can create a query using `Database.query(T)` or `Session.query(T)`. We recommend Sessions as it improves performance. +You can create a query using `Database.query(T)` or `Session.query(T)`. We recommend Sessions as it improves performance and provides automatic change detection. ```typescript @entity.name('user') @@ -19,8 +19,12 @@ class User { const database = new Database(...); -//[ { username: 'User1' }, { username: 'User2' }, { username: 'User2' } ] +//[ { username: 'User1' }, { username: 'User2' }, { username: 'User3' } ] const users = await database.query(User).select('username').find(); + +// Using session (recommended for better performance) +const session = database.createSession(); +const users = await session.query(User).select('username').find(); ``` ## Filter @@ -353,6 +357,120 @@ class UserQuery extends Query { return this.filter({$and: [{birthdate: {$gte: start}}, {birthdate: {$lte: end}}]} as FilterQuery); } } +``` + +## Aggregation + +Deepkit ORM supports powerful aggregation functions that allow you to perform calculations on groups of data. These functions are particularly useful for analytics and reporting. + +### Basic Aggregation Functions + +```typescript +@entity.name('product') +class Product { + id: number & PrimaryKey & AutoIncrement = 0; + category: string = ''; + title: string = ''; + price: number = 0; + rating: number = 0; +} + +const database = new Database(...); + +// Sum all prices +const totalValue = await database.query(Product).withSum('price').find(); +// Result: [{ price: 12345 }] + +// Count all products +const productCount = await database.query(Product).withCount('id', 'total').find(); +// Result: [{ total: 150 }] + +// Get minimum price +const cheapest = await database.query(Product).withMin('price').find(); +// Result: [{ price: 9.99 }] + +// Get maximum price +const mostExpensive = await database.query(Product).withMax('price').find(); +// Result: [{ price: 999.99 }] + +// Calculate average price +const avgPrice = await database.query(Product).withAverage('price').find(); +// Result: [{ price: 125.50 }] +``` + +### Group By with Aggregation + +Combine `groupBy()` with aggregation functions to perform calculations per group: + +```typescript +// Group by category and sum prices +const categoryTotals = await database.query(Product) + .groupBy('category') + .withSum('price') + .orderBy('category') + .find(); +// Result: [ +// { category: 'electronics', price: 5000 }, +// { category: 'books', price: 250 } +// ] + +// Count products per category +const categoryCounts = await database.query(Product) + .groupBy('category') + .withCount('id', 'productCount') + .orderBy('category') + .find(); +// Result: [ +// { category: 'electronics', productCount: 25 }, +// { category: 'books', productCount: 100 } +// ] + +// Multiple aggregations +const categoryStats = await database.query(Product) + .groupBy('category') + .withCount('id', 'count') + .withSum('price', 'totalValue') + .withAverage('price', 'avgPrice') + .withMin('price', 'cheapest') + .withMax('price', 'mostExpensive') + .orderBy('category') + .find(); +``` + +### Group Concatenation + +The `withGroupConcat()` function concatenates values within each group: + +```typescript +// Concatenate all product titles per category +const categoryProducts = await database.query(Product) + .groupBy('category') + .withGroupConcat('title') + .find(); + +// Note: Result format depends on database adapter +// SQLite: [{ category: 'books', title: 'Book1,Book2,Book3' }] +// MongoDB: [{ category: 'books', title: ['Book1', 'Book2', 'Book3'] }] +``` + +### Filtering Aggregated Results + +You can filter both before and after aggregation: + +```typescript +// Filter before aggregation (affects input data) +const expensiveCategoryTotals = await database.query(Product) + .filter({ price: { $gt: 100 } }) // Only expensive products + .groupBy('category') + .withSum('price') + .find(); + +// Filter after aggregation (affects aggregated results) +const highValueCategories = await database.query(Product) + .groupBy('category') + .withSum('price') + .filter({ price: { $gt: 1000 } }) // Only categories with total > 1000 + .find(); await session.query(User).lift(UserQuery).hasBirthday().find(); ``` diff --git a/website/src/pages/documentation/orm/raw-access.md b/website/src/pages/documentation/orm/raw-access.md index c6712c3db..f86716501 100644 --- a/website/src/pages/documentation/orm/raw-access.md +++ b/website/src/pages/documentation/orm/raw-access.md @@ -1,39 +1,153 @@ # Raw Access -It's often necessary to access the database directly, for example to run a SQL query that is not supported by the ORM. -This can be done using the `raw` method on the `Database` class. +Raw access allows you to execute database-specific queries when the ORM's query builder doesn't provide the functionality you need. This is particularly useful for complex queries, database-specific features, or performance-critical operations. + +## SQL Databases + +For SQL databases, use the `sql` template literal to create safe, parameterized queries: ```typescript -import { PrimaryKey, AutoIncrement, @entity } from '@deepkit/type'; +import { PrimaryKey, AutoIncrement, entity } from '@deepkit/type'; import { Database } from '@deepkit/orm'; -import { sql } from '@deepkit/sql'; -import { SqliteDatabaseAdapter } from '@deepkit/sqlite'; +import { sql, identifier } from '@deepkit/sql'; +import { SQLiteDatabaseAdapter } from '@deepkit/sqlite'; -@entity.collection('users') +@entity.name('user') class User { id: number & PrimaryKey & AutoIncrement = 0; created: Date = new Date; - constructor(public username: string) {} + username: string = ''; + email: string = ''; + age: number = 0; } const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User]); -const query = 'Pet%'; -const rows = await database.raw(sql`SELECT * FROM users WHERE username LIKE ${query}`).find(); - -const result = await database.raw<{ count: number }>(sql`SELECT count(*) as count FROM users WHERE username LIKE ${query}`).findOne(); +// Basic query with parameters +const searchTerm = 'Pet%'; +const users = await database.raw(sql` + SELECT * FROM user + WHERE username LIKE ${searchTerm} +`).find(); + +// Count query +const result = await database.raw<{ count: number }>(sql` + SELECT count(*) as count + FROM user + WHERE username LIKE ${searchTerm} +`).findOne(); console.log('Found', result.count, 'users'); ``` -The SQL query is built using the `sql` template string tag. This is a special template string tag that allows to pass values as parameters. These parameters are then automatically parsed and converted to a safe prepared statement. This is important to avoid SQL injection attacks. +### Advanced SQL Examples + +```typescript +// Complex aggregation query +type UserStats = { + ageGroup: string; + userCount: number; + avgAge: number; +}; + +const stats = await database.raw(sql` + SELECT + CASE + WHEN age < 18 THEN 'Under 18' + WHEN age BETWEEN 18 AND 30 THEN '18-30' + WHEN age BETWEEN 31 AND 50 THEN '31-50' + ELSE 'Over 50' + END as ageGroup, + COUNT(*) as userCount, + AVG(age) as avgAge + FROM user + GROUP BY ageGroup + ORDER BY avgAge +`).find(); + +// Window functions (PostgreSQL/MySQL) +type UserRanking = { + id: number; + username: string; + age: number; + ageRank: number; +}; + +const rankings = await database.raw(sql` + SELECT + id, + username, + age, + ROW_NUMBER() OVER (ORDER BY age DESC) as ageRank + FROM user + WHERE age > 18 +`).find(); +``` + +## SQL Template Literals + +The `sql` template literal provides safe parameterization and prevents SQL injection attacks. All values are automatically escaped and converted to prepared statement parameters. + +### Dynamic Identifiers -To pass a dynamic identifier like a column name, `identifier` can be used: +For dynamic column names or table names, you can use the `identifier()` function from `@deepkit/sql`: ```typescript import { identifier, sql } from '@deepkit/sql'; -let column = 'username'; -const rows = await database.raw(sql`SELECT * FROM users WHERE ${identifier(column)} LIKE ${query}`).find(); +// Dynamic column selection +const column = 'username'; +const sortColumn = 'created'; +const users = await database.raw(sql` + SELECT ${identifier(column)} + FROM user + WHERE ${identifier(column)} LIKE ${'Pet%'} + ORDER BY ${identifier(sortColumn)} DESC +`).find(); + +// For most cases, you can reference columns directly in the template +const users2 = await database.raw(sql` + SELECT username, email + FROM user + WHERE username LIKE ${'Pet%'} + ORDER BY created DESC +`).find(); +``` + +### Entity References in SQL + +You can reference entity classes directly in SQL queries: + +```typescript +// Use entity class as table reference +const users = await database.raw(sql` + SELECT * FROM ${User} + WHERE age > ${18} +`).find(); + +// Join with multiple entities +@entity.name('order') +class Order { + id: number & PrimaryKey & AutoIncrement = 0; + userId: number & Reference = 0; + amount: number = 0; +} + +type UserOrderSummary = { + username: string; + totalOrders: number; + totalAmount: number; +}; + +const summary = await database.raw(sql` + SELECT + u.username, + COUNT(o.id) as totalOrders, + SUM(o.amount) as totalAmount + FROM ${User} u + LEFT JOIN ${Order} o ON u.id = o.userId + GROUP BY u.id, u.username + HAVING totalOrders > 0 +`).find(); ``` For SQL adapters, the `raw` method returns an `RawQuery` with `findOne` and `find` methods to retrieve the results. To execute a SQL without returning rows like UPDATE/DELETE/etc, `execute` can be used: diff --git a/website/src/pages/documentation/orm/session.md b/website/src/pages/documentation/orm/session.md index 2d49f41ed..ca241e0da 100644 --- a/website/src/pages/documentation/orm/session.md +++ b/website/src/pages/documentation/orm/session.md @@ -48,6 +48,216 @@ await session.commit();//saves all users ## Identity Map +The identity map is a key feature of sessions that ensures each entity is loaded only once and maintains object identity: + +```typescript +const session = database.createSession(); + +// First query loads user into identity map +const user1 = await session.query(User).filter({ id: 1 }).findOne(); + +// Second query returns the same instance from identity map (no database hit) +const user2 = await session.query(User).filter({ id: 1 }).findOne(); + +console.log(user1 === user2); // true - same object reference + +// Changes to either reference affect the same object +user1.username = 'updated'; +console.log(user2.username); // 'updated' + +await session.commit(); // Saves the changes +``` + +### Identity Map Benefits: +- **Performance**: Prevents duplicate database queries for the same entity +- **Consistency**: Ensures all references to an entity are the same object +- **Change Tracking**: Automatically detects modifications to entities + +### Identity Map Considerations: +- **Memory Usage**: Sessions hold references to all loaded entities +- **Scope**: Identity map is per-session, not global +- **Lifecycle**: Entities remain in memory until session is garbage collected + +## Change Detection + +Sessions automatically track changes to entities loaded through the session: + +```typescript +const session = database.createSession(); + +// Load entity through session +const user = await session.query(User).findOne(); + +// Modify properties - changes are automatically tracked +user.username = 'newUsername'; +user.email = 'new@email.com'; +user.lastLogin = new Date(); + +// Commit automatically generates UPDATE statement for changed fields only +await session.commit(); +// SQL: UPDATE user SET username = ?, email = ?, lastLogin = ? WHERE id = ? +``` + +### Manual Change Tracking: +```typescript +import { atomicChange } from '@deepkit/type'; + +const session = database.createSession(); +const user = await session.query(User).findOne(); + +// Use atomicChange for complex modifications +atomicChange(user).username = 'newUsername'; +atomicChange(user).profile.bio = 'Updated bio'; + +await session.commit(); +``` + +## Session Lifecycle Management + +Proper session management is crucial for performance and memory usage: + +### Request-Response Pattern: +```typescript +// Good: Create session per request +async function handleUserRequest(userId: number) { + const session = database.createSession(); + + try { + const user = await session.query(User).filter({ id: userId }).findOne(); + user.lastAccess = new Date(); + + const orders = await session.query(Order) + .filter({ userId: user.id }) + .find(); + + await session.commit(); + + return { user, orders }; + } catch (error) { + // Session automatically rolls back on error + throw error; + } + // Session is garbage collected when function exits +} +``` + +### Long-Running Process Pattern: +```typescript +// Good: Manage session lifecycle in long-running processes +async function processBatchData(dataItems: any[]) { + const batchSize = 100; + + for (let i = 0; i < dataItems.length; i += batchSize) { + const session = database.createSession(); + + try { + const batch = dataItems.slice(i, i + batchSize); + + for (const item of batch) { + const entity = new MyEntity(item.data); + session.add(entity); + } + + await session.commit(); + } catch (error) { + console.error(`Batch ${i}-${i + batchSize} failed:`, error); + // Continue with next batch + } + + // Session is cleaned up after each batch + } +} +``` + +## Advanced Session Operations + +### Bulk Operations: +```typescript +const session = database.createSession(); + +// Add multiple entities +const users = [ + new User('user1', 'user1@example.com'), + new User('user2', 'user2@example.com'), + new User('user3', 'user3@example.com') +]; + +users.forEach(user => session.add(user)); +await session.commit(); // Single batch insert + +// Remove multiple entities +const oldUsers = await session.query(User) + .filter({ lastLogin: { $lt: oldDate } }) + .find(); + +oldUsers.forEach(user => session.remove(user)); +await session.commit(); // Single batch delete +``` + +### Partial Updates: +```typescript +const session = database.createSession(); + +// Load only specific fields +const user = await session.query(User) + .select('id', 'username', 'email') + .findOne(); + +// Modify loaded fields +user.username = 'updated'; + +// Only modified fields are updated in database +await session.commit(); +``` + +## Performance Best Practices + +### When to Use Sessions: +- ✅ Multiple related database operations +- ✅ Need automatic change detection +- ✅ Working with entity relationships +- ✅ Implementing unit of work patterns +- ✅ Batch operations + +### When to Use Direct Database Queries: +- ✅ Single read operations +- ✅ Simple aggregations +- ✅ Bulk updates/deletes without loading entities +- ✅ Read-only operations + +### Memory Management: +```typescript +// Bad: Long-lived session accumulates entities +const globalSession = database.createSession(); + +async function processUser(userId: number) { + // This accumulates users in identity map + const user = await globalSession.query(User).findOne(); + // ... process user +} + +// Good: Short-lived sessions +async function processUser(userId: number) { + const session = database.createSession(); + const user = await session.query(User).findOne(); + // ... process user + // Session is garbage collected +} + +// Good: Manual cleanup for long-running sessions +const session = database.createSession(); +try { + // Process many entities + for (const id of userIds) { + const user = await session.query(User).filter({ id }).findOne(); + // ... process user + } +} finally { + // Clear identity map to free memory + session.identityMap.clear(); +} +``` + Sessions provide an identity map that ensures there is only ever one javascript object per database entry. For example, if you run `session.query(User).find()` twice within the same session, you get two different arrays, but with the same entity instances in them. If you add a new entity with `session.add(entity1)` and retrieve it again, you get exactly the same entity instance `entity1`. diff --git a/website/src/pages/documentation/orm/testing.md b/website/src/pages/documentation/orm/testing.md new file mode 100644 index 000000000..8bda73f14 --- /dev/null +++ b/website/src/pages/documentation/orm/testing.md @@ -0,0 +1,344 @@ +# Testing with Deepkit ORM + +Testing database-driven applications requires careful consideration of data isolation, performance, and test reliability. Deepkit ORM provides several tools and patterns to make testing easier and more effective. + +## Database Adapters for Testing + +### SQLite Database Adapter (Recommended) + +For most testing scenarios, we recommend using `SQLiteDatabaseAdapter` with in-memory databases. This provides the best balance of performance, SQL compatibility, and feature support: + +```typescript +import { SQLiteDatabaseAdapter } from '@deepkit/sqlite'; +import { Database } from '@deepkit/orm'; +import { entity, PrimaryKey, AutoIncrement } from '@deepkit/type'; + +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + created: Date = new Date(); + + constructor(public username: string, public email: string) {} +} + +describe('User Service', () => { + let database: Database; + + beforeEach(async () => { + // Create fresh in-memory SQLite database for each test + database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User]); + await database.migrate(); + }); + + afterEach(async () => { + await database.disconnect(); + }); + + test('should create user', async () => { + const user = new User('john', 'john@example.com'); + await database.persist(user); + + const found = await database.query(User).findOne(); + expect(found.username).toBe('john'); + expect(found.id).toBe(1); + }); +}); +``` + +### Memory Database Adapter + +The `MemoryDatabaseAdapter` is useful for simple unit tests but has limitations with complex SQL features: + +```typescript +import { MemoryDatabaseAdapter } from '@deepkit/orm'; +import { Database } from '@deepkit/orm'; +import { entity, PrimaryKey, AutoIncrement } from '@deepkit/type'; + +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + created: Date = new Date(); + + constructor(public username: string, public email: string) {} +} + +describe('User Service', () => { + let database: Database; + + beforeEach(async () => { + // Create fresh database for each test + database = new Database(new MemoryDatabaseAdapter(), [User]); + await database.migrate(); + }); + + afterEach(async () => { + // Clean up + await database.disconnect(); + }); + + test('should create user', async () => { + const user = new User('john', 'john@example.com'); + await database.persist(user); + + const found = await database.query(User).findOne(); + expect(found.username).toBe('john'); + expect(found.id).toBe(1); + }); + + test('should find users by email', async () => { + await database.persist( + new User('john', 'john@example.com'), + new User('jane', 'jane@example.com') + ); + + const user = await database.query(User) + .filter({ email: 'jane@example.com' }) + .findOne(); + + expect(user.username).toBe('jane'); + }); +}); +``` + +## Test Database Setup + +For integration tests, you might want to use a real database. Here's a pattern for setting up test databases: + +```typescript +import { SQLiteDatabaseAdapter } from '@deepkit/sqlite'; +import { PostgresDatabaseAdapter } from '@deepkit/postgres'; + +// Test configuration +const TEST_CONFIG = { + // Recommended: SQLite in-memory for fast, reliable tests + sqlite: () => new SQLiteDatabaseAdapter(':memory:'), + // For integration tests that need specific database features + postgres: () => new PostgresDatabaseAdapter({ + host: process.env.TEST_DB_HOST || 'localhost', + database: process.env.TEST_DB_NAME || 'test_db', + username: process.env.TEST_DB_USER || 'test', + password: process.env.TEST_DB_PASSWORD || 'test', + }) +}; + +function createTestDatabase(entities: any[], adapter: 'sqlite' | 'postgres' = 'sqlite') { + return new Database(TEST_CONFIG[adapter](), entities); +} + +describe('Integration Tests', () => { + let database: Database; + + beforeAll(async () => { + // Use SQLite for fast, reliable integration tests + database = createTestDatabase([User, Order, Product], 'sqlite'); + await database.migrate(); + }); + + beforeEach(async () => { + // Clear all data before each test + await database.query(Order).deleteMany(); + await database.query(User).deleteMany(); + await database.query(Product).deleteMany(); + }); + + afterAll(async () => { + await database.disconnect(); + }); +}); +``` + +## Testing with Sessions + +When testing code that uses sessions, ensure proper session management: + +```typescript +describe('Session-based operations', () => { + let database: Database; + + beforeEach(async () => { + database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User]); + await database.migrate(); + }); + + test('should track changes in session', async () => { + const session = database.createSession(); + + // Add new user + const user = new User('john', 'john@example.com'); + session.add(user); + + // Modify existing user + const existingUser = await session.query(User).findOneOrUndefined(); + if (existingUser) { + existingUser.username = 'john_updated'; + } + + // Commit all changes + await session.commit(); + + // Verify changes + const updated = await database.query(User).findOne(); + expect(updated.username).toBe('john_updated'); + }); + + test('should rollback on error', async () => { + const session = database.createSession(); + + try { + session.add(new User('john', 'john@example.com')); + + // Simulate error + throw new Error('Something went wrong'); + + await session.commit(); + } catch (error) { + // Session automatically rolls back on error + } + + // Verify no data was persisted + const count = await database.query(User).count(); + expect(count).toBe(0); + }); +}); +``` + +## Testing Transactions + +Test transaction behavior to ensure data consistency: + +```typescript +describe('Transaction tests', () => { + test('should rollback on transaction failure', async () => { + const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User, Order]); + await database.migrate(); + + const session = database.createSession(); + + try { + await session.transaction(async () => { + // Add user + const user = new User('john', 'john@example.com'); + session.add(user); + await session.flush(); // Persist user to get ID + + // Add order + const order = new Order(user.id, 100.00); + session.add(order); + + // Simulate error that should rollback everything + if (order.amount > 50) { + throw new Error('Order amount too high'); + } + }); + } catch (error) { + // Transaction rolled back + } + + // Verify nothing was persisted + expect(await database.query(User).count()).toBe(0); + expect(await database.query(Order).count()).toBe(0); + }); +}); +``` + +## Testing with Fixtures + +Create reusable test data fixtures: + +```typescript +class TestFixtures { + static async createUsers(database: Database, count: number = 3): Promise { + const users = []; + for (let i = 1; i <= count; i++) { + users.push(new User(`user${i}`, `user${i}@example.com`)); + } + await database.persist(...users); + return users; + } + + static async createCompleteOrderScenario(database: Database) { + const users = await this.createUsers(database, 2); + + const products = [ + new Product('Laptop', 999.99, 'electronics'), + new Product('Book', 19.99, 'books'), + ]; + await database.persist(...products); + + const orders = [ + new Order(users[0].id, products[0].id, 1), + new Order(users[1].id, products[1].id, 2), + ]; + await database.persist(...orders); + + return { users, products, orders }; + } +} + +describe('E-commerce tests', () => { + let database: Database; + + beforeEach(async () => { + database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User, Product, Order]); + await database.migrate(); + }); + + test('should calculate total revenue', async () => { + const { orders } = await TestFixtures.createCompleteOrderScenario(database); + + const revenue = await database.query(Order) + .withSum('amount', 'total') + .findOne(); + + expect(revenue.total).toBeGreaterThan(0); + }); +}); +``` + +## Performance Testing + +Test query performance and identify bottlenecks: + +```typescript +describe('Performance tests', () => { + test('should handle large datasets efficiently', async () => { + const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User]); + await database.migrate(); + + // Create large dataset + const users = []; + for (let i = 0; i < 10000; i++) { + users.push(new User(`user${i}`, `user${i}@example.com`)); + } + + const startTime = Date.now(); + await database.persist(...users); + const insertTime = Date.now() - startTime; + + expect(insertTime).toBeLessThan(5000); // Should complete in under 5 seconds + + // Test query performance + const queryStart = Date.now(); + const found = await database.query(User) + .filter({ username: { $regex: /user1.*/ } }) + .limit(100) + .find(); + const queryTime = Date.now() - queryStart; + + expect(queryTime).toBeLessThan(1000); // Should complete in under 1 second + expect(found.length).toBeGreaterThan(0); + }); +}); +``` + +## Best Practices + +1. **Isolation**: Each test should be independent and not rely on data from other tests +2. **Clean State**: Always start with a clean database state +3. **SQLite for Testing**: Use `SQLiteDatabaseAdapter` with `:memory:` for most tests - it's fast and supports full SQL features +4. **Memory Adapter**: Use `MemoryDatabaseAdapter` only for simple unit tests that don't need complex SQL features +5. **Real Database**: Use production database adapters for integration tests that need specific database features +6. **Fixtures**: Create reusable test data generators +7. **Transactions**: Test both success and failure scenarios +8. **Performance**: Include performance tests for critical queries +9. **Cleanup**: Always disconnect from databases in test teardown From d186739746eb32929901020c933467e8c205211e Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 21:24:59 +0200 Subject: [PATCH 09/15] docs: improve runtime types --- .../src/pages/documentation/runtime-types.md | 128 ++++- .../runtime-types/getting-started.md | 460 +++++++++++++++--- .../runtime-types/troubleshooting.md | 38 +- .../runtime-types/type-guards.md | 64 ++- .../documentation/runtime-types/types.md | 221 +++++++-- 5 files changed, 801 insertions(+), 110 deletions(-) diff --git a/website/src/pages/documentation/runtime-types.md b/website/src/pages/documentation/runtime-types.md index 90e44c663..d9f3049b0 100644 --- a/website/src/pages/documentation/runtime-types.md +++ b/website/src/pages/documentation/runtime-types.md @@ -1,9 +1,129 @@ # Runtime Types -Runtime type information in TypeScript unlocks new workflows and features that were previously unavailable or required workarounds. Modern development processes rely heavily on declaring types and schemas for tools like GraphQL, validators, ORMs, and encoders such as ProtoBuf. These tools may require developers to learn new languages specific to their use case, like ProtoBuf and GraphQL having their own declaration language, or validators using their own schema APIs or JSON-Schema. +## What are Runtime Types? -TypeScript has become powerful enough to describe complex structures and even replace declaration formats like GraphQL, ProtoBuf, and JSON-Schema entirely. With a runtime type system, it's possible to cover the use cases of these tools without any code generators or runtime JavaScript type declaration libraries like "Zod". The Deepkit library aims to provide runtime type information and make it easier to develop efficient and compatible solutions. +Runtime type information in TypeScript unlocks powerful workflows that were previously impossible or required complex workarounds. Traditionally, TypeScript types exist only during compilation and are completely erased at runtime. This means that while you can write `function process(user: User)`, your code has no way to know what `User` looks like when the function actually runs. -Deepkit is built upon the ability to read type information at runtime, using as much TypeScript type information as possible for efficiency. The runtime type system allows reading and computing dynamic types, such as class properties, function parameters, and return types. Deepkit hooks into TypeScript's compilation process to ensure that all type information is embedded into the generated JavaScript using a [custom bytecode and virtual machine](https://github.com/microsoft/TypeScript/issues/47658), enabling developers to access type information programmatically. +Deepkit Runtime Types changes this by preserving TypeScript type information at runtime, enabling your code to: -With Deepkit, developers can use their existing TypeScript types for validation, serialisation and more at runtime, simplifying their development process and making their work more efficient. +- **Validate data** against TypeScript types automatically +- **Serialize and deserialize** complex objects with type safety +- **Reflect on types** to build dynamic functionality +- **Cast and transform** data between different formats +- **Generate schemas** from TypeScript types + +## Why Runtime Types Matter + +### The Traditional Problem + +Modern development relies heavily on type definitions for various tools: + +- **APIs**: GraphQL schemas, OpenAPI specifications +- **Validation**: JSON Schema, Zod, Joi +- **Databases**: ORM entity definitions +- **Serialization**: ProtoBuf definitions, custom encoders + +Each of these tools typically requires you to: +1. Learn their specific schema language +2. Maintain separate type definitions +3. Keep TypeScript types and schemas in sync manually +4. Write boilerplate code for conversions + +### The Deepkit Solution + +With Deepkit Runtime Types, your TypeScript types become the single source of truth: + +```typescript +// Define once in TypeScript +interface User { + id: number; + email: string & Email; + name: string & MinLength<2>; + createdAt: Date; +} + +// Use everywhere automatically +const isValid = is(data); // Validation +const user = cast(jsonData); // Deserialization +const json = serialize(user); // Serialization +const schema = typeOf(); // Reflection +``` + +## How It Works + +Deepkit uses a custom TypeScript transformer that: + +1. **Analyzes your TypeScript code** during compilation +2. **Generates efficient bytecode** representing your types +3. **Embeds this bytecode** into your JavaScript output +4. **Provides a runtime VM** to execute type operations + +This approach is: +- **Zero-overhead**: Types are compiled to efficient bytecode +- **Complete**: Supports all TypeScript features including generics, unions, mapped types +- **Automatic**: No manual schema definitions required +- **Type-safe**: Full TypeScript integration and inference + +## Core Capabilities + +### 🔍 Type Validation +Validate any data against TypeScript types with detailed error reporting: + +```typescript +const errors = validate(userData); +if (errors.length === 0) { + // userData is guaranteed to be a valid User +} +``` + +### 🔄 Serialization & Casting +Convert between different data formats while preserving type information: + +```typescript +// JSON string → TypeScript object with proper types +const user = cast({ + id: "123", // → number + createdAt: "2023-01-01" // → Date object +}); +``` + +### 🪞 Type Reflection +Inspect and manipulate types programmatically: + +```typescript +const reflection = ReflectionClass.from(); +const properties = reflection.getProperties(); +const emailProp = reflection.getProperty('email'); +``` + +### 🛡️ Type Guards & Assertions +Safe runtime type checking with TypeScript integration: + +```typescript +if (is(data)) { + // TypeScript knows data is User here + console.log(data.email); // ✅ Type-safe access +} +``` + +## Key Benefits + +- **Single Source of Truth**: Define types once in TypeScript, use everywhere +- **Zero Boilerplate**: No manual schema definitions or converters +- **Full TypeScript Support**: Works with interfaces, classes, generics, unions, and more +- **High Performance**: Compiled bytecode executes faster than reflection-based solutions +- **Developer Experience**: Full IDE support with autocomplete and type checking +- **Ecosystem Integration**: Powers Deepkit's HTTP, RPC, ORM, and validation systems + +## Getting Started + +Ready to add runtime type capabilities to your TypeScript project? Start with our [Getting Started Guide](./getting-started.md) to learn installation and basic usage. + +## Learn More + +- **[Getting Started](./getting-started.md)**: Installation and first steps +- **[Validation](./validation.md)**: Validate data against TypeScript types +- **[Serialization](./serialization.md)**: Convert between data formats +- **[Reflection](./reflection.md)**: Inspect and manipulate types at runtime +- **[Type Guards](./type-guards.md)**: Safe runtime type checking +- **[Types](./types.md)**: Supported TypeScript features and annotations diff --git a/website/src/pages/documentation/runtime-types/getting-started.md b/website/src/pages/documentation/runtime-types/getting-started.md index 0ecb7af5e..f7fdc843a 100644 --- a/website/src/pages/documentation/runtime-types/getting-started.md +++ b/website/src/pages/documentation/runtime-types/getting-started.md @@ -1,19 +1,45 @@ -# Getting started +# Getting Started -To install Deepkit's runtime type system two packages are needed: The Deepkit Type Compiler and the Deepkit Type package itself. The type compiler is a TypeScript transformer that generates runtime type information from TypeScript types. The type package contains the runtime virtual machine and type annotations as well as many useful functions for working with types. +Welcome to Deepkit Runtime Types! This guide will walk you through setting up and using runtime type information in your TypeScript project. + +## What You'll Learn + +By the end of this guide, you'll understand: +- How to install and configure Deepkit Runtime Types +- The difference between compile-time and runtime types +- How to validate, cast, and serialize data using TypeScript types +- How to use type reflection for dynamic programming + +## Prerequisites + +- **Node.js** 16 or higher +- **TypeScript** 4.7 or higher +- Basic understanding of TypeScript types and interfaces ## Installation -```sh +Deepkit Runtime Types consists of two packages: + +1. **`@deepkit/type`**: The runtime library with validation, serialization, and reflection APIs +2. **`@deepkit/type-compiler`**: The TypeScript transformer that generates runtime type information + +Install both packages: + +```bash npm install --save @deepkit/type npm install --save-dev @deepkit/type-compiler typescript ts-node ``` -Runtime type information is not generated by default. It must be set `"reflection": true` in the `tsconfig.json` file to enable it. +### Why Two Packages? -If decorators are to be used, `"experimentalDecorators": true` must be enabled in `tsconfig.json`. This is not strictly necessary to work with `@deepkit/type`, but necessary for certain functions of other Deepkit libraries and in Deepkit Framework. +- **`@deepkit/type`** contains the runtime code that your application uses +- **`@deepkit/type-compiler`** is a build-time tool that transforms your TypeScript code to include type information -_File: tsconfig.json_ +## Configuration + +### Enable Type Reflection + +Add `"reflection": true` to your `tsconfig.json` to enable runtime type generation: ```json { @@ -27,123 +53,319 @@ _File: tsconfig.json_ } ``` -## Quick Start Examples +### Configuration Options Explained + +- **`"reflection": true`**: **Required** - Enables runtime type information generation +- **`"experimentalDecorators": true`**: **Recommended** - Enables decorator support for advanced features and other Deepkit libraries + +### Verify Installation + +The type compiler automatically installs itself into your local TypeScript installation. You can verify this worked by running: + +```bash +node_modules/.bin/deepkit-type-install +``` + +This command should complete without errors. If you see issues, see the [Troubleshooting](#troubleshooting) section below. + +## Your First Runtime Types + +Let's start with a simple example to see runtime types in action. + +### Understanding the Problem + +In regular TypeScript, types disappear at runtime: + +```typescript +interface User { + username: string; + email: string; +} + +function processUser(data: any) { + // ❌ No way to check if data is actually a User at runtime + // ❌ TypeScript types don't exist here + return data.username.toUpperCase(); // Might crash! +} +``` + +### The Deepkit Solution -### Basic Type Casting and Validation +With Deepkit Runtime Types, your TypeScript types become available at runtime: -Write your first code with runtime type information: +```typescript +import { is, cast, validate } from '@deepkit/type'; -_File: app.ts_ +interface User { + username: string; + email: string; +} + +function processUser(data: any) { + // ✅ Check if data matches User interface + if (is(data)) { + // TypeScript knows data is User here + return data.username.toUpperCase(); // Safe! + } + throw new Error('Invalid user data'); +} +``` + +### Basic Example: User Validation + +Create a file `app.ts` and try this example: ```typescript -import { cast, MinLength, ReflectionClass, validate, is } from '@deepkit/type'; +import { cast, validate, is, MinLength, Email } from '@deepkit/type'; +// Define a User interface with validation constraints interface User { username: string & MinLength<3>; + email: string & Email; birthDate?: Date; } -// Type casting with automatic conversion +// Example 1: Type Guards - Safe runtime checking +function checkUserType(data: unknown) { + if (is(data)) { + console.log('✅ Valid user:', data.username); + return true; + } + console.log('❌ Not a valid user'); + return false; +} + +// Example 2: Validation - Get detailed error information +function validateUser(data: unknown) { + const errors = validate(data); + if (errors.length === 0) { + console.log('✅ User is valid!'); + } else { + console.log('❌ Validation errors:'); + errors.forEach(error => { + console.log(` - ${error.path}: ${error.message}`); + }); + } + return errors; +} + +// Example 3: Casting - Convert and validate data +function createUser(data: unknown) { + try { + const user = cast(data); + console.log('✅ User created:', user); + console.log('Birth date type:', typeof user.birthDate); // 'object' (Date) + return user; + } catch (error) { + console.log('❌ Failed to create user:', error.message); + return null; + } +} + +// Test the functions +console.log('=== Testing Type Guards ==='); +checkUserType({ username: 'john', email: 'john@example.com' }); +checkUserType({ username: 'jo' }); // Too short + +console.log('\n=== Testing Validation ==='); +validateUser({ username: 'john', email: 'john@example.com' }); +validateUser({ username: 'jo', email: 'invalid-email' }); + +console.log('\n=== Testing Casting ==='); +createUser({ + username: 'peter', + email: 'peter@example.com', + birthDate: '2010-10-10T00:00:00Z' // String → Date conversion +}); +``` + +### Run Your Example + +Execute your code with `ts-node`: + +```bash +./node_modules/.bin/ts-node app.ts +``` + +You should see output showing successful validation, type conversion, and error reporting. + +### What Just Happened? + +Let's break down what Deepkit Runtime Types did: + +1. **Type Guards (`is`)**: Checked if data matches the User interface structure at runtime +2. **Validation (`validate`)**: Provided detailed error messages for invalid data +3. **Casting (`cast`)**: Converted string dates to Date objects and validated constraints +4. **Constraint Validation**: Enforced `MinLength<3>` and `Email` constraints automatically + +## Core Concepts + +### 1. Type Guards vs Validation + +**Type Guards** (`is`) return a boolean and narrow TypeScript types: +```typescript +if (is(data)) { + // TypeScript knows data is User here + data.username; // ✅ Type-safe access +} +``` + +**Validation** (`validate`) returns detailed error information: +```typescript +const errors = validate(data); +errors.forEach(error => { + console.log(`${error.path}: ${error.message}`); +}); +``` + +### 2. Casting vs Deserialization + +**Casting** (`cast`) validates AND converts data: +```typescript const user = cast({ - username: 'Peter', - birthDate: '2010-10-10T00:00:00Z' + username: 'john', + birthDate: '2023-01-01' // String → Date }); -console.log(user); -// Output: { username: 'Peter', birthDate: 2010-10-10T00:00:00.000Z } +``` -// Reflection - inspect types at runtime -const reflection = ReflectionClass.from(); -console.log(reflection.getProperty('username').type); +**Deserialization** (`deserialize`) converts without validation: +```typescript +const user = deserialize(jsonData); // Faster, no validation +``` -// Validation - check if data matches type constraints -const errors = validate({ username: 'Jo', birthDate: new Date() }); -console.log(errors); -// Output: [{ path: 'username', code: 'minLength', message: 'Min length is 3' }] +### 3. Type Annotations -// Type guards - runtime type checking -if (is('hello')) { - console.log('Value is a string'); +Add validation constraints directly to your types: +```typescript +interface User { + username: string & MinLength<3> & MaxLength<20>; + email: string & Email; + age: number & Positive & Maximum<120>; } ``` -### Working with Complex Types +Available constraints include: +- `MinLength`, `MaxLength` - String/array length +- `Email` - Email format validation +- `Positive`, `Negative` - Number constraints +- `Pattern` - Custom regex validation +- `Validate` - Custom validation functions + +## Advanced Example: E-commerce Order + +Here's a more complex example showing nested objects, arrays, and multiple constraints: ```typescript -import { cast, validate, Email, MaxLength } from '@deepkit/type'; +import { cast, validate, Email, MaxLength, Positive, MinLength } from '@deepkit/type'; interface Product { - id: number; - name: string & MaxLength<100>; - price: number; + id: number & Positive; + name: string & MinLength<1> & MaxLength<100>; + price: number & Positive; tags: string[]; metadata?: Record; } interface Order { - id: number; + id: number & Positive; customerEmail: string & Email; products: Product[]; - total: number; + total: number & Positive; createdAt: Date; } -// Cast complex nested data +// Raw data from API/form const orderData = { id: 1, customerEmail: 'customer@example.com', products: [ { id: 1, - name: 'Laptop', - price: 999.99, - tags: ['electronics', 'computers'], + name: 'Gaming Laptop', + price: 1299.99, + tags: ['electronics', 'computers', 'gaming'], metadata: { brand: 'TechCorp', warranty: '2 years' } + }, + { + id: 2, + name: 'Wireless Mouse', + price: 49.99, + tags: ['electronics', 'accessories'] } ], - total: 999.99, - createdAt: '2023-01-01T10:00:00Z' + total: 1349.98, + createdAt: '2023-01-01T10:00:00Z' // String will be converted to Date }; -const order = cast(orderData); -console.log(order.createdAt instanceof Date); // true +try { + // Cast and validate the entire order structure + const order = cast(orderData); + + console.log('✅ Order processed successfully!'); + console.log('Order ID:', order.id); + console.log('Customer:', order.customerEmail); + console.log('Products:', order.products.length); + console.log('Created:', order.createdAt instanceof Date); // true + console.log('Total: $', order.total); -// Validate the entire order -const validationErrors = validate(order); -if (validationErrors.length === 0) { - console.log('Order is valid!'); +} catch (error) { + console.log('❌ Order validation failed:', error.message); } ``` -And run it with `ts-node`: - -```sh -./node_modules/.bin/ts-node app.ts -``` +This example demonstrates: +- **Nested object validation** (Order contains Products) +- **Array validation** (products array, tags array) +- **Multiple constraints** (Positive numbers, string lengths, email format) +- **Automatic type conversion** (string → Date) +- **Optional properties** (metadata?) ## Interactive Example -## Type compiler +## How the Type Compiler Works + +### Automatic Installation + +The Deepkit type compiler automatically integrates with your TypeScript installation: -TypeScript itself does not allow to configure the type compiler via a `tsconfig.json`. It is necessary to either use the TypeScript compiler API directly or a build system like Webpack with _ts-loader_. To save this inconvenient way for Deepkit users, the Deepkit type compiler automatically installs itself in `node_modules/typescript` once `@deepkit/type-compiler` is installed (this is done via NPM install hooks). -This makes it possible for all build tools that access the locally installed TypeScript (the one in `node_modules/typescript`) to automatically have the type compiler enabled. This makes _tsc_, Angular, webpack, _ts-node_, and some other tools automatically work with the deepkit type compiler. +1. **NPM Install Hooks**: When you install `@deepkit/type-compiler`, it automatically patches your local TypeScript installation +2. **Universal Compatibility**: Works with `tsc`, `ts-node`, webpack, Angular, and other TypeScript tools +3. **Zero Configuration**: No additional build setup required -If the type compiler could not be successfully installed automatically (for example because NPM install hooks are disabled), this can be done manually with the following command: +### Manual Installation -```sh +If automatic installation fails (e.g., NPM hooks disabled), install manually: + +```bash node_modules/.bin/deepkit-type-install ``` -Note that `deepkit-type-install` must be run if the local typescript version has been updated (for example, if the typescript version in package.json has changed and `npm install` is run). +**When to run manually:** +- NPM install hooks are disabled in your environment +- After updating TypeScript version +- If you see "Type compiler not installed" errors + +### Troubleshooting + +**Problem**: "Type information not available" errors +**Solution**: Ensure `"reflection": true` is in your `tsconfig.json` -## Webpack +**Problem**: Type compiler not working with build tools +**Solution**: Run `node_modules/.bin/deepkit-type-install` manually -If you want to use the type compiler in a webpack build, you can do so with the `ts-loader` package (or any other typescript loader that supports transformer registration). +**Problem**: Types not updating after changes +**Solution**: Restart your TypeScript compiler/dev server -_File: webpack.config.js_ +## Build Tool Integration + +### Webpack Configuration + +For webpack projects, configure the type compiler with `ts-loader`: ```javascript +// webpack.config.js const typeCompiler = require('@deepkit/type-compiler'); module.exports = { @@ -152,19 +374,115 @@ module.exports = { rules: [ { test: /\.tsx?$/, - use: { - loader: 'ts-loader', - options: { - //this enables @deepkit/type's type compiler - getCustomTransformers: (program, getProgram) => ({ - before: [typeCompiler.transformer], - afterDeclarations: [typeCompiler.declarationTransformer], - }), - } - }, - exclude: /node_modules/, - }, + use: { + loader: 'ts-loader', + options: { + // Enable Deepkit type compiler + getCustomTransformers: (program, getProgram) => ({ + before: [typeCompiler.transformer], + afterDeclarations: [typeCompiler.declarationTransformer], + }), + } + }, + exclude: /node_modules/, + }, ], }, + resolve: { + extensions: ['.tsx', '.ts', '.js'], + }, +}; +``` + +### Other Build Tools + +The type compiler works automatically with: +- **tsc** (TypeScript compiler) +- **ts-node** (Node.js TypeScript execution) +- **Angular CLI** (via TypeScript integration) +- **Vite** (with TypeScript plugin) +- **esbuild** (with TypeScript plugin) + +## Next Steps + +Now that you have runtime types working, explore these key features: + +### 🔍 **[Validation](./validation.md)** +Learn comprehensive data validation with detailed error reporting: +- Built-in constraints (email, length, numeric ranges) +- Custom validators +- Nested object validation +- Error handling patterns + +### 🔄 **[Serialization](./serialization.md)** +Master data conversion between formats: +- JSON serialization/deserialization +- Type-safe casting +- Custom serializers +- Performance optimization + +### 🪞 **[Reflection](./reflection.md)** +Discover dynamic programming with type introspection: +- Inspect class properties and methods +- Build generic utilities +- Runtime type analysis +- Meta-programming patterns + +### 🛡️ **[Type Guards](./type-guards.md)** +Implement safe runtime type checking: +- Custom type guard functions +- Union type discrimination +- Type narrowing patterns +- Integration with control flow + +### 📚 **[Types Reference](./types.md)** +Explore all supported TypeScript features: +- Primitive types and literals +- Complex types (unions, intersections, generics) +- Type annotations and constraints +- Advanced type patterns + +## Common Patterns + +### API Data Validation +```typescript +// Validate incoming API data +app.post('/users', (req, res) => { + try { + const user = cast(req.body); + // user is guaranteed to be valid + await saveUser(user); + res.json({ success: true }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); +``` + +### Configuration Loading +```typescript +// Validate configuration files +interface Config { + port: number & Positive; + database: { + host: string; + port: number & Positive; + }; +} + +const config = cast(JSON.parse(configFile)); +``` + +### Form Data Processing +```typescript +// Process form submissions +interface ContactForm { + name: string & MinLength<2>; + email: string & Email; + message: string & MinLength<10>; } + +const formData = cast(formInput); ``` + +You're now ready to use Deepkit Runtime Types in your projects! Start with validation and casting, then explore the advanced features as your needs grow. diff --git a/website/src/pages/documentation/runtime-types/troubleshooting.md b/website/src/pages/documentation/runtime-types/troubleshooting.md index d802fa6af..a3e1a37ad 100644 --- a/website/src/pages/documentation/runtime-types/troubleshooting.md +++ b/website/src/pages/documentation/runtime-types/troubleshooting.md @@ -1,6 +1,42 @@ # Troubleshooting -This guide covers common issues and their solutions when working with Deepkit's runtime type system. +This comprehensive guide helps you resolve common issues when working with Deepkit Runtime Types. Issues are organized by category with step-by-step solutions. + +## Quick Diagnosis + +Before diving into specific solutions, try these quick checks: + +1. **Verify basic setup**: + ```bash + # Check if packages are installed + npm list @deepkit/type @deepkit/type-compiler + + # Verify tsconfig.json has reflection enabled + grep -A 5 -B 5 "reflection" tsconfig.json + ``` + +2. **Test with a simple example**: + ```typescript + import { is } from '@deepkit/type'; + + // This should work if everything is set up correctly + console.log(is('hello')); // Should print: true + console.log(is('hello')); // Should print: false + ``` + +3. **Check for error messages**: + - "No type received" → Type compiler issue + - "ValidationError" → Data validation issue + - "Cannot read property" → Missing type information + +## Common Error Messages + +| Error | Likely Cause | Quick Fix | +|-------|--------------|-----------| +| "No type received" | Type compiler not working | Check tsconfig.json, reinstall type compiler | +| "Type information not available" | Missing `reflection: true` | Add to tsconfig.json | +| "ValidationError: Not a ..." | Data doesn't match type | Check your data structure | +| "Cannot resolve type" | Import/export issue | Verify type is properly exported | ## Installation Issues diff --git a/website/src/pages/documentation/runtime-types/type-guards.md b/website/src/pages/documentation/runtime-types/type-guards.md index 6fdc961e5..241162cfb 100644 --- a/website/src/pages/documentation/runtime-types/type-guards.md +++ b/website/src/pages/documentation/runtime-types/type-guards.md @@ -1,6 +1,68 @@ # Type Guards and Assertions -Type guards and assertions are essential tools for runtime type checking in TypeScript. Deepkit provides powerful functions that not only check types at runtime but also provide TypeScript with the necessary type information for proper type narrowing. +## What are Type Guards? + +Type guards are functions that perform runtime checks to determine if a value matches a specific type. They serve two crucial purposes: + +1. **Runtime Safety**: Verify that data actually matches expected types at runtime +2. **Type Narrowing**: Inform TypeScript's type system about the verified type + +### The Problem: TypeScript Types Don't Exist at Runtime + +Consider this common scenario: + +```typescript +interface User { + id: number; + username: string; + email: string; +} + +function processUser(data: any) { + // ❌ Dangerous: No runtime verification + return data.username.toUpperCase(); // Might crash if data.username is undefined +} + +// This could crash your application +processUser({}); // TypeError: Cannot read property 'toUpperCase' of undefined +``` + +### The Solution: Runtime Type Checking + +With Deepkit type guards, you can safely verify types at runtime: + +```typescript +import { is } from '@deepkit/type'; + +function processUser(data: unknown) { + if (is(data)) { + // ✅ Safe: TypeScript knows data is User here + return data.username.toUpperCase(); // Guaranteed to work + } + throw new Error('Invalid user data'); +} +``` + +## When to Use Type Guards + +Type guards are essential when dealing with: + +- **API responses** - External data that might not match your interfaces +- **User input** - Form data, URL parameters, file uploads +- **Configuration files** - JSON/YAML configs that could be malformed +- **Database results** - Data that might have changed schema +- **Message queues** - Inter-service communication data +- **Third-party libraries** - Data from external packages + +## Core Functions + +Deepkit provides three main functions for runtime type checking: + +| Function | Purpose | Returns | Use Case | +|----------|---------|---------|----------| +| `is()` | Type guard | `boolean` | Conditional logic, safe access | +| `assert()` | Type assertion | `void` (throws on fail) | Fail-fast validation | +| `validate()` | Detailed validation | `ValidationError[]` | User-friendly error messages | ## Type Guards with `is` diff --git a/website/src/pages/documentation/runtime-types/types.md b/website/src/pages/documentation/runtime-types/types.md index 393858372..a3db960d0 100644 --- a/website/src/pages/documentation/runtime-types/types.md +++ b/website/src/pages/documentation/runtime-types/types.md @@ -1,60 +1,215 @@ -# Type Annotations +# Types and Annotations -Type annotations are normal TypeScript types that contain meta-information that can be read and change the behavior of various functions at runtime. Deepkit already provides some type annotations that cover many use cases. For example, a class property can be marked as primary key, reference, or index. The database library can use this information at runtime to create the correct SQL queries without prior code generation. +## What are Type Annotations? -Validator constraints such as `MaxLength`, `Maximum`, or `Positive` can also be added to any type. It is also possible to tell the serializer how to serialize or deserialize a particular value. In addition, it is possible to create completely custom type annotations and read them at runtime, in order to use the type system at runtime in a very individual way. +Type annotations in Deepkit are special TypeScript types that add metadata to your regular types. They provide additional information that can be used at runtime for validation, serialization, database operations, and more. -Deepkit comes with a whole set of type annotations, all of which can be used directly from `@deepkit/type`. They are designed not to come from multiple libraries, so as not to tie code directly to a particular library such as Deepkit RPC or Deepkit Database. This allows easier reuse of types, even in the frontend, although database type annotations are used for example. +### How Type Annotations Work -Following is a list of existing type annotations. The validator and serializer of `@deepkit/type` and `@deepkit/bson` and Deepkit Database of `@deepkit/orm` used this information differently. See the corresponding chapters to learn more about this. +Type annotations use TypeScript's intersection types (`&`) to attach metadata to base types: -## Integer/Float +```typescript +// Base type +type Username = string; + +// With annotations +type Username = string & MinLength<3> & MaxLength<20>; + +// Multiple annotations +type Price = number & Positive & Maximum<10000>; +``` + +### Why Use Type Annotations? + +Type annotations enable: + +- **Validation**: Automatic data validation based on type constraints +- **Serialization**: Custom serialization behavior for different targets +- **Database Mapping**: ORM field configuration and relationships +- **API Documentation**: Automatic schema generation +- **Code Generation**: Type-driven code generation -Integer and floats are defined as a base as `number` and has several sub-variants: +### Annotation Categories -| Type | Description | -|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| integer | An integer of arbitrary size. | -| int8 | An integer between -128 and 127. | -| uint8 | An integer between 0 and 255. | -| int16 | An integer between -32768 and 32767. | -| uint16 | An integer between 0 and 65535. | -| int32 | An integer between -2147483648 and 2147483647. | -| uint32 | An integer between 0 and 4294967295. | -| float | Same as number, but might have different meaning in database context. | -| float32 | A float between -3.40282347e+38 and 3.40282347e+38. Note that JavaScript is not able to check correctly the range due to precision issues, but the information might be handy for the database or binary serializers. | -| float64 | Same as number, but might have different meaning in database context. | +Deepkit provides annotations in several categories: + +| Category | Purpose | Examples | +|----------|---------|----------| +| **Validation** | Data validation constraints | `MinLength`, `Email`, `Positive` | +| **Serialization** | Control serialization behavior | `Group`, `Excluded`, `MapName` | +| **Database** | ORM field configuration | `PrimaryKey`, `Reference`, `Index` | +| **Numeric** | Specialized number types | `integer`, `float32`, `UUID` | +| **Custom** | User-defined metadata | `Data`, `Validate` | + +## Basic Usage + +### Adding Constraints ```typescript -import { integer } from '@deepkit/type'; +import { MinLength, MaxLength, Email, Positive } from '@deepkit/type'; interface User { - id: integer; + // String with length constraints + username: string & MinLength<3> & MaxLength<20>; + + // Email validation + email: string & Email; + + // Positive number + age: number & Positive; + + // Optional field with constraints + bio?: string & MaxLength<500>; } ``` -Here the `id` of the user is a number at runtime, but is interpreted as an integer in the validation and serialization. -This means that here, for example, no floats may be used in validation and the serializer automatically converts floats into integers. +### Combining with Validation ```typescript -import { is, integer } from '@deepkit/type'; +import { validate, cast } from '@deepkit/type'; + +const userData = { + username: 'jo', // Too short + email: 'invalid-email', // Invalid format + age: -5 // Negative number +}; -is(12); //true -is(12.5); //false +const errors = validate(userData); +console.log(errors); +// [ +// { path: 'username', code: 'minLength', message: 'Min length is 3' }, +// { path: 'email', code: 'pattern', message: 'Pattern ^\\S+@\\S+$ does not match' }, +// { path: 'age', code: 'positive', message: 'Number must be positive' } +// ] ``` -The subtypes can be used in the same way and are useful if a specific range of numbers is to be allowed. +## Supported TypeScript Features + +Deepkit Runtime Types supports the complete TypeScript type system: + +### Primitive Types +- `string`, `number`, `boolean`, `bigint`, `symbol` +- `null`, `undefined`, `void`, `never`, `any`, `unknown` + +### Complex Types +- **Objects**: `{ name: string; age: number }` +- **Arrays**: `string[]`, `Array` +- **Tuples**: `[string, number, boolean]` +- **Functions**: `(x: number) => string` +- **Classes**: Full class support with inheritance + +### Advanced Types +- **Unions**: `string | number | boolean` +- **Intersections**: `A & B & C` +- **Generics**: `Array`, `Promise`, `Map` +- **Conditional Types**: `T extends U ? X : Y` +- **Mapped Types**: `{ [K in keyof T]: T[K] }` +- **Template Literals**: `` `prefix-${string}` `` + +### Example: Complex Type Structure ```typescript -import { is, int8 } from '@deepkit/type'; +interface ApiResponse { + success: boolean; + data?: T; + error?: string; + metadata: { + timestamp: Date; + version: string; + }; +} + +type UserResponse = ApiResponse; +type ProductListResponse = ApiResponse; -is(-5); //true -is(5); //true -is(-200); //false -is(2500); //false +// All of these work with runtime validation +const userResponse = cast(apiData); +const productList = cast(productData); ``` -## Float +## Numeric Type Annotations + +### Integer Types + +Deepkit provides specialized integer types that are validated at runtime and provide hints for serialization and database storage: + +| Type | Range | Description | +|------|-------|-------------| +| `integer` | Unlimited | Any integer value | +| `int8` | -128 to 127 | 8-bit signed integer | +| `uint8` | 0 to 255 | 8-bit unsigned integer | +| `int16` | -32,768 to 32,767 | 16-bit signed integer | +| `uint16` | 0 to 65,535 | 16-bit unsigned integer | +| `int32` | -2,147,483,648 to 2,147,483,647 | 32-bit signed integer | +| `uint32` | 0 to 4,294,967,295 | 32-bit unsigned integer | + +```typescript +import { integer, int8, uint16, is, cast } from '@deepkit/type'; + +interface Product { + id: integer; // Any integer + categoryId: uint16; // 0-65535 + stockLevel: int8; // -128 to 127 +} + +// Validation examples +is(42); // true +is(42.5); // false - not an integer +is(100); // true +is(200); // false - outside range +is(65000); // true +is(-1); // false - negative not allowed + +// Automatic conversion during casting +const product = cast({ + id: "123", // String → integer + categoryId: 42.7, // Float → uint16 (rounded) + stockLevel: "5" // String → int8 +}); +``` + +### Float Types + +Float types provide precision hints for databases and binary serializers: + +| Type | Description | +|------|-------------| +| `float` | Generic floating-point number | +| `float32` | 32-bit floating-point (single precision) | +| `float64` | 64-bit floating-point (double precision) | + +```typescript +import { float32, float64 } from '@deepkit/type'; + +interface Measurement { + temperature: float32; // Single precision + precision: float64; // Double precision +} +``` + +### Why Use Specialized Numeric Types? + +1. **Validation**: Ensure values are within expected ranges +2. **Database Optimization**: Use appropriate column types +3. **Binary Serialization**: Optimize storage size +4. **API Documentation**: Clear type specifications + +```typescript +interface GameScore { + playerId: uint32; // Player IDs are always positive + score: integer; // Scores can be negative + level: uint8; // Levels 1-255 + accuracy: float32; // Percentage with single precision +} + +// This will validate ranges automatically +const score = cast({ + playerId: 12345, + score: -100, + level: 5, + accuracy: 95.7 +}); +``` ## UUID From 8a1c1ee4ca5852b0202501b073e5583a0d7367a2 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 21:28:14 +0200 Subject: [PATCH 10/15] docs: improve orm --- .../src/pages/documentation/orm/relations.md | 190 +++++++++++++++++- 1 file changed, 189 insertions(+), 1 deletion(-) diff --git a/website/src/pages/documentation/orm/relations.md b/website/src/pages/documentation/orm/relations.md index 347f35046..399fd39ce 100644 --- a/website/src/pages/documentation/orm/relations.md +++ b/website/src/pages/documentation/orm/relations.md @@ -148,6 +148,194 @@ const users = await database.query(UserGroup) ## One To One +A one-to-one relationship connects exactly one record in one entity to exactly one record in another entity. In Deepkit ORM, one-to-one relationships are implemented using `Reference` on the owning side and `BackReference` on the referenced side. + +### Basic One-to-One Relationship + +The most common pattern is where one entity has a reference to another, and the referenced entity has a back reference: + +```typescript +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + + // Back reference to the credentials + credentials?: UserCredentials & BackReference; + + constructor(public name: string) { + } +} + +@entity.name('user-credentials') +class UserCredentials { + password: string = ''; + + constructor( + // Reference to the user - this is the owning side + public user: User & PrimaryKey & Reference + ) { + } +} +``` + +In this example, `UserCredentials` owns the relationship by storing a reference to `User` as its primary key. The `User` entity has a virtual back reference to access the credentials. + +### Primary Key as Foreign Key + +A common pattern for one-to-one relationships is to use the referenced entity's primary key as both the primary key and foreign key of the referencing entity: + +```typescript +@entity.name('phoneNumber') +class PhoneNumber { + id: number & PrimaryKey & AutoIncrement = 0; + + // Back reference to sim details + details?: SimDetails & BackReference; + + constructor(public msisdn: string) { + } + + country: string = ''; + active: boolean = true; +} + +@entity.name('simDetails') +class SimDetails { + // The phone number reference serves as both primary key and foreign key + constructor( + public number: PhoneNumber & Reference & PrimaryKey + ) { + } + + iccid: string = ''; + imsi: string = ''; + pin: string = ''; + puk: string = ''; +} +``` + +This pattern ensures that each `SimDetails` record is uniquely associated with exactly one `PhoneNumber`. + +### Working with One-to-One Relations + +Creating and persisting one-to-one related entities: + +```typescript +const database = new Database( + new SQLiteDatabaseAdapter(':memory:'), + [User, UserCredentials] +); +await database.migrate(); + +// Create entities +const user = new User('john_doe'); +const credentials = new UserCredentials(user); +credentials.password = 'securePassword123'; + +// Persist both entities +await database.persist(user, credentials); +``` + +### Querying One-to-One Relations + +To load the related entity, use `joinWith()`: + +```typescript +// Load user with credentials +const userWithCredentials = await database.query(User) + .joinWith('credentials') + .filter({ name: 'john_doe' }) + .findOne(); + +console.log(userWithCredentials.credentials?.password); // 'securePassword123' + +// Load credentials with user +const credentialsWithUser = await database.query(UserCredentials) + .joinWith('user') + .filter({ password: 'securePassword123' }) + .findOne(); + +console.log(credentialsWithUser.user.name); // 'john_doe' +``` + +### Circular References + +One-to-one relationships can also be circular, where entities reference each other: + +```typescript +@entity.name('inventory') +class Inventory { + id: number & PrimaryKey & AutoIncrement = 0; + + constructor(public user: User & Reference) { + } +} + +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + + // Each user has exactly one inventory + inventory: Inventory & BackReference = new Inventory(this); +} +``` + +In this pattern, the entities are created together and maintain a bidirectional one-to-one relationship. + ## Constraints -On Delete/Update: RESTRICT | CASCADE | SET NULL | NO ACTION | SET DEFAULT +Foreign key constraints define what happens when a referenced record is deleted or updated. Deepkit ORM supports several constraint options that can be specified on `Reference` fields. + +### Constraint Options + +- **CASCADE**: When the referenced record is deleted/updated, automatically delete/update the referencing records +- **RESTRICT**: Prevent deletion/update of the referenced record if there are referencing records +- **SET NULL**: Set the reference field to null when the referenced record is deleted/updated +- **NO ACTION**: No automatic action (default behavior) +- **SET DEFAULT**: Set the reference field to its default value when the referenced record is deleted/updated + +### Using CASCADE + +The `CASCADE` constraint is particularly useful for maintaining data integrity in one-to-one and one-to-many relationships: + +```typescript +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + + constructor(public name: string) { + } +} + +@entity.name('profile') +class Profile { + id: number & PrimaryKey & AutoIncrement = 0; + + constructor( + // When user is deleted, profile will be automatically deleted + public user: User & Reference<{onDelete: 'CASCADE'}> + ) { + } +} +``` + +### Constraint Examples + +```typescript +// SET NULL: Reference becomes null when target is deleted +class Post { + author?: User & Reference<{onDelete: 'SET NULL'}>; +} + +// RESTRICT: Prevents deletion of user if posts exist +class Post { + author: User & Reference<{onDelete: 'RESTRICT'}>; +} + +// SET DEFAULT: Uses default value when target is deleted +class Post { + author: User & Reference<{onDelete: 'SET DEFAULT'}> = defaultUser; +} +``` + +When no constraint is specified, the default behavior is `CASCADE` for most relationships. From 7ca5d868b66d81ded5d8e25a46a4b42265a4c494 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 21:40:53 +0200 Subject: [PATCH 11/15] docs: improve dependency injection --- .../documentation/dependency-injection.md | 23 + .../dependency-injection/advanced-patterns.md | 475 ++++++++++++++++++ .../dependency-injection/configuration.md | 269 ++++++++-- .../dependency-injection/debugging.md | 341 +++++++++++++ .../dependency-injection/getting-started.md | 200 ++++++-- .../dependency-injection/injection.md | 247 ++++++++- .../dependency-injection/providers.md | 177 ++++++- .../dependency-injection/scopes.md | 217 +++++++- .../dependency-injection/testing.md | 453 +++++++++++++++++ 9 files changed, 2274 insertions(+), 128 deletions(-) create mode 100644 website/src/pages/documentation/dependency-injection/advanced-patterns.md create mode 100644 website/src/pages/documentation/dependency-injection/debugging.md create mode 100644 website/src/pages/documentation/dependency-injection/testing.md diff --git a/website/src/pages/documentation/dependency-injection.md b/website/src/pages/documentation/dependency-injection.md index 8d5a60d29..76eaba599 100644 --- a/website/src/pages/documentation/dependency-injection.md +++ b/website/src/pages/documentation/dependency-injection.md @@ -197,3 +197,26 @@ It should be noted here that although in theory the dependency inversion princip Design patterns should not be used blindly and across the board for even the simplest code. However, if the prerequisites such as a complex architecture, large applications, or a scaling team are given, dependency inversion and other design patterns only unfold their true strength. +## Deepkit Dependency Injection Guide + +Now that you understand the concepts, explore Deepkit's powerful dependency injection system: + +### Getting Started +- **[Getting Started](./dependency-injection/getting-started.md)** - Installation, basic usage, and choosing the right API level +- **[Injection Patterns](./dependency-injection/injection.md)** - Constructor, property, and parameter injection techniques + +### Core Concepts +- **[Providers](./dependency-injection/providers.md)** - Different provider types, lifecycle management, and tagged providers +- **[Scopes](./dependency-injection/scopes.md)** - Request-scoped services, scope isolation, and performance considerations +- **[Configuration](./dependency-injection/configuration.md)** - Type-safe configuration injection and validation + +### Advanced Topics +- **[Advanced Patterns](./dependency-injection/advanced-patterns.md)** - Plugin architecture, factory patterns, decorators, and middleware +- **[Performance](./dependency-injection/performance.md)** - Optimization strategies, benchmarking, and best practices +- **[Testing](./dependency-injection/testing.md)** - Unit testing, mocking, and integration testing with DI + +### Troubleshooting +- **[Debugging & Error Handling](./dependency-injection/debugging.md)** - Common errors, debugging techniques, and prevention strategies + +Each guide includes practical examples, best practices, and real-world patterns to help you build maintainable, testable applications with Deepkit's dependency injection system. + diff --git a/website/src/pages/documentation/dependency-injection/advanced-patterns.md b/website/src/pages/documentation/dependency-injection/advanced-patterns.md new file mode 100644 index 000000000..45cd7e404 --- /dev/null +++ b/website/src/pages/documentation/dependency-injection/advanced-patterns.md @@ -0,0 +1,475 @@ +# Advanced Patterns + +This guide covers advanced dependency injection patterns and techniques for building sophisticated applications with Deepkit's DI system. + +## Plugin Architecture + +Build extensible applications using tagged providers and dynamic loading: + +```typescript +import { Tag } from '@deepkit/injector'; + +// Define plugin interface +interface Plugin { + name: string; + initialize(): void; + process(data: any): any; +} + +// Create plugin tag +class PluginTag extends Tag {} + +// Implement plugins +class ValidationPlugin implements Plugin { + name = 'validation'; + + initialize() { + console.log('Validation plugin initialized'); + } + + process(data: any) { + // Validate data + return { ...data, validated: true }; + } +} + +class TransformPlugin implements Plugin { + name = 'transform'; + + initialize() { + console.log('Transform plugin initialized'); + } + + process(data: any) { + // Transform data + return { ...data, transformed: true }; + } +} + +// Plugin manager +class PluginManager { + constructor(private plugins: PluginTag) {} + + initialize() { + for (const plugin of this.plugins.services) { + plugin.initialize(); + } + } + + processData(data: any) { + let result = data; + for (const plugin of this.plugins.services) { + result = plugin.process(result); + } + return result; + } +} + +// Setup +const injector = Injector.from([ + PluginManager, + PluginTag.provide(ValidationPlugin), + PluginTag.provide(TransformPlugin), +]); + +const manager = injector.get(PluginManager); +manager.initialize(); +``` + +## Factory Pattern with DI + +Create objects with some dependencies injected and others provided manually: + +```typescript +import { PartialFactory } from '@deepkit/injector'; + +// Service that needs both injected and manual dependencies +class ReportGenerator { + constructor( + private database: Database, // Injected + private logger: Logger, // Injected + private template: string, // Manual + private options: ReportOptions // Manual + ) {} + + async generate(): Promise { + this.logger.log(`Generating report with template: ${this.template}`); + const data = await this.database.query(this.options.query); + return this.processData(data); + } +} + +// Factory service +class ReportService { + constructor( + private reportFactory: PartialFactory + ) {} + + async generateUserReport(userId: string): Promise { + const generator = this.reportFactory({ + template: 'user-report', + options: { query: `SELECT * FROM users WHERE id = ${userId}` } + }); + return generator.generate(); + } + + async generateSalesReport(period: string): Promise { + const generator = this.reportFactory({ + template: 'sales-report', + options: { query: `SELECT * FROM sales WHERE period = '${period}'` } + }); + return generator.generate(); + } +} +``` + +## Decorator Pattern with DI + +Enhance services with cross-cutting concerns: + +```typescript +// Base service interface +interface UserService { + getUser(id: string): Promise; + updateUser(id: string, data: Partial): Promise; +} + +// Core implementation +class CoreUserService implements UserService { + constructor(private repository: UserRepository) {} + + async getUser(id: string): Promise { + return this.repository.findById(id); + } + + async updateUser(id: string, data: Partial): Promise { + return this.repository.update(id, data); + } +} + +// Caching decorator +class CachedUserService implements UserService { + constructor( + private inner: UserService, + private cache: CacheService + ) {} + + async getUser(id: string): Promise { + const cached = await this.cache.get(`user:${id}`); + if (cached) return cached; + + const user = await this.inner.getUser(id); + await this.cache.set(`user:${id}`, user, 300); + return user; + } + + async updateUser(id: string, data: Partial): Promise { + const user = await this.inner.updateUser(id, data); + await this.cache.delete(`user:${id}`); + return user; + } +} + +// Logging decorator +class LoggedUserService implements UserService { + constructor( + private inner: UserService, + private logger: Logger + ) {} + + async getUser(id: string): Promise { + this.logger.log(`Getting user ${id}`); + const user = await this.inner.getUser(id); + this.logger.log(`Retrieved user ${id}: ${user.name}`); + return user; + } + + async updateUser(id: string, data: Partial): Promise { + this.logger.log(`Updating user ${id}`, data); + const user = await this.inner.updateUser(id, data); + this.logger.log(`Updated user ${id}`); + return user; + } +} + +// Setup with decorator chain +const injector = Injector.from([ + UserRepository, + CacheService, + Logger, + CoreUserService, + { + provide: UserService, + useFactory: ( + core: CoreUserService, + cache: CacheService, + logger: Logger + ) => { + // Build decorator chain: Logging -> Caching -> Core + const cached = new CachedUserService(core, cache); + return new LoggedUserService(cached, logger); + } + } +]); +``` + +## Strategy Pattern with DI + +Implement interchangeable algorithms using dependency injection: + +```typescript +// Strategy interface +interface PaymentProcessor { + process(amount: number, details: PaymentDetails): Promise; +} + +// Strategy implementations +class CreditCardProcessor implements PaymentProcessor { + constructor(private gateway: CreditCardGateway) {} + + async process(amount: number, details: PaymentDetails): Promise { + return this.gateway.charge(amount, details.cardNumber, details.cvv); + } +} + +class PayPalProcessor implements PaymentProcessor { + constructor(private api: PayPalAPI) {} + + async process(amount: number, details: PaymentDetails): Promise { + return this.api.createPayment(amount, details.email); + } +} + +class BankTransferProcessor implements PaymentProcessor { + constructor(private service: BankService) {} + + async process(amount: number, details: PaymentDetails): Promise { + return this.service.transfer(amount, details.accountNumber); + } +} + +// Strategy factory +class PaymentProcessorFactory { + constructor(private injector: InjectorContext) {} + + getProcessor(type: 'credit-card' | 'paypal' | 'bank-transfer'): PaymentProcessor { + switch (type) { + case 'credit-card': + return this.injector.get(CreditCardProcessor); + case 'paypal': + return this.injector.get(PayPalProcessor); + case 'bank-transfer': + return this.injector.get(BankTransferProcessor); + default: + throw new Error(`Unknown payment type: ${type}`); + } + } +} + +// Payment service using strategy +class PaymentService { + constructor(private factory: PaymentProcessorFactory) {} + + async processPayment( + type: string, + amount: number, + details: PaymentDetails + ): Promise { + const processor = this.factory.getProcessor(type as any); + return processor.process(amount, details); + } +} +``` + +## Observer Pattern with DI + +Implement event-driven architecture with dependency injection: + +```typescript +// Event types +interface UserCreatedEvent { + type: 'user.created'; + user: User; + timestamp: Date; +} + +interface UserUpdatedEvent { + type: 'user.updated'; + user: User; + changes: Partial; + timestamp: Date; +} + +type UserEvent = UserCreatedEvent | UserUpdatedEvent; + +// Event handler interface +interface EventHandler { + handle(event: T): Promise; +} + +// Event handlers +class EmailNotificationHandler implements EventHandler { + constructor(private emailService: EmailService) {} + + async handle(event: UserCreatedEvent): Promise { + await this.emailService.sendWelcomeEmail(event.user); + } +} + +class AuditLogHandler implements EventHandler { + constructor(private auditService: AuditService) {} + + async handle(event: UserEvent): Promise { + await this.auditService.log(event.type, event); + } +} + +class CacheInvalidationHandler implements EventHandler { + constructor(private cache: CacheService) {} + + async handle(event: UserUpdatedEvent): Promise { + await this.cache.delete(`user:${event.user.id}`); + } +} + +// Event bus with DI +class EventBus { + private handlers = new Map[]>(); + + constructor(private injector: InjectorContext) {} + + subscribe(eventType: string, handlerClass: ClassType>) { + if (!this.handlers.has(eventType)) { + this.handlers.set(eventType, []); + } + + const handler = this.injector.get(handlerClass); + this.handlers.get(eventType)!.push(handler); + } + + async publish(event: T & { type: string }): Promise { + const handlers = this.handlers.get(event.type) || []; + + await Promise.all( + handlers.map(handler => handler.handle(event)) + ); + } +} + +// Setup +const injector = Injector.from([ + EmailService, + AuditService, + CacheService, + EmailNotificationHandler, + AuditLogHandler, + CacheInvalidationHandler, + EventBus +]); + +const eventBus = injector.get(EventBus); + +// Subscribe handlers +eventBus.subscribe('user.created', EmailNotificationHandler); +eventBus.subscribe('user.created', AuditLogHandler); +eventBus.subscribe('user.updated', AuditLogHandler); +eventBus.subscribe('user.updated', CacheInvalidationHandler); +``` + +## Middleware Pattern with DI + +Build processing pipelines with dependency injection: + +```typescript +// Middleware interface +interface Middleware { + process(context: T, next: () => Promise): Promise; +} + +// Middleware implementations +class AuthenticationMiddleware implements Middleware { + constructor(private authService: AuthService) {} + + async process(context: RequestContext, next: () => Promise): Promise { + const token = context.request.headers.authorization; + context.user = await this.authService.validateToken(token); + await next(); + } +} + +class LoggingMiddleware implements Middleware { + constructor(private logger: Logger) {} + + async process(context: RequestContext, next: () => Promise): Promise { + const start = Date.now(); + this.logger.log(`Request started: ${context.request.url}`); + + await next(); + + const duration = Date.now() - start; + this.logger.log(`Request completed in ${duration}ms`); + } +} + +class ValidationMiddleware implements Middleware { + constructor(private validator: ValidationService) {} + + async process(context: RequestContext, next: () => Promise): Promise { + await this.validator.validate(context.request.body); + await next(); + } +} + +// Middleware pipeline +class MiddlewarePipeline { + private middlewares: Middleware[] = []; + + constructor(private injector: InjectorContext) {} + + use(middlewareClass: ClassType>): this { + const middleware = this.injector.get(middlewareClass); + this.middlewares.push(middleware); + return this; + } + + async execute(context: T, finalHandler: () => Promise): Promise { + let index = 0; + + const next = async (): Promise => { + if (index < this.middlewares.length) { + const middleware = this.middlewares[index++]; + await middleware.process(context, next); + } else { + await finalHandler(); + } + }; + + await next(); + } +} + +// Usage +const pipeline = new MiddlewarePipeline(injector); + +pipeline + .use(LoggingMiddleware) + .use(AuthenticationMiddleware) + .use(ValidationMiddleware); + +// Execute pipeline +await pipeline.execute(requestContext, async () => { + // Final request handler + console.log('Processing request...'); +}); +``` + +## Best Practices for Advanced Patterns + +1. **Keep Interfaces Simple**: Design focused interfaces for better testability +2. **Use Composition**: Prefer composition over inheritance for flexibility +3. **Leverage Tags**: Use tagged providers for plugin and collection patterns +4. **Factory for Flexibility**: Use factories when you need runtime configuration +5. **Scope Appropriately**: Only use scopes when you need different lifecycles +6. **Test Patterns**: Ensure your patterns are easily testable with mocks + +These patterns demonstrate the power and flexibility of Deepkit's dependency injection system for building maintainable, extensible applications. diff --git a/website/src/pages/documentation/dependency-injection/configuration.md b/website/src/pages/documentation/dependency-injection/configuration.md index fab726b67..07b07d097 100644 --- a/website/src/pages/documentation/dependency-injection/configuration.md +++ b/website/src/pages/documentation/dependency-injection/configuration.md @@ -1,92 +1,289 @@ # Configuration -The dependency injection container also allows configuration options to be injected. This configuration injection can be received via constructor injection or property injection. +Deepkit's dependency injection system provides a powerful, type-safe configuration system that allows you to inject configuration values directly into your services. Configuration is defined using regular TypeScript classes, providing both type safety and default values. -The Module API supports the definition of a configuration definition, which is a regular class. By providing such a class with properties, each property acts as a configuration option. Because of the way classes can be defined in TypeScript, this allows defining a type and default values per property. +## Configuration Classes + +Configuration is defined using classes where each property represents a configuration option: ```typescript -class RootConfiguration { - domain: string = 'localhost'; +class DatabaseConfig { + host: string = 'localhost'; + port: number = 5432; + username: string = 'admin'; + password!: string; // Required - no default value + ssl: boolean = false; + maxConnections: number = 10; +} + +class AppConfig { debug: boolean = false; + apiUrl: string = 'https://api.example.com'; + database: DatabaseConfig = new DatabaseConfig(); } const rootModule = new InjectorModule([UserRepository]) - .setConfigDefinition(RootConfiguration) - .addImport(lowLevelModule); + .setConfigDefinition(AppConfig); ``` -The configuration options `domain` and `debug` can now be used quite conveniently typesafe in providers. +**Benefits of Class-based Configuration:** +- **Type Safety**: Full TypeScript type checking +- **Default Values**: Specify defaults directly in the class +- **Nested Configuration**: Use composition for complex config structures +- **IDE Support**: Autocomplete and refactoring support + +## Injecting Configuration + +Configuration can be injected in several ways: + +### Single Property Injection + +Inject individual configuration properties using index access: ```typescript class UserRepository { - constructor(private debug: RootConfiguration['debug']) {} + constructor( + private debug: AppConfig['debug'], + private dbHost: AppConfig['database']['host'] + ) {} getUsers() { - if (this.debug) console.debug('fetching users ...'); + if (this.debug) { + console.debug(`Fetching users from ${this.dbHost}`); + } + // ... } } ``` -The values of the options themselves can be set via `configure()`. +### Partial Configuration Injection + +Inject multiple related properties using `Pick`: ```typescript - rootModule.configure({debug: true}); +class EmailService { + constructor( + private config: Pick + ) {} + + sendEmail(to: string, subject: string) { + if (this.config.debug) { + console.log(`Would send email to ${to}: ${subject}`); + return; + } + + // Use this.config.apiUrl for actual sending + } +} ``` -Options that do not have a default value but are still necessary can be provided with a `!`. This forces the user of the module to provide the value, otherwise an error will occur. +### Full Configuration Injection + +Inject the entire configuration object: ```typescript -class RootConfiguration { - domain!: string; +class ApplicationService { + constructor(private config: AppConfig) {} + + initialize() { + console.log(`Starting app in ${this.config.debug ? 'debug' : 'production'} mode`); + console.log(`Database: ${this.config.database.host}:${this.config.database.port}`); + } } ``` -## Validation +## Setting Configuration Values -Also, all serialization and validation types from the previous chapters [Validation](validation.md) and [Serialization](serialization.md) can be used to specify in great detail what type and content restrictions an option must have. +Use the `configure()` method to set configuration values: ```typescript -class RootConfiguration { - domain!: string & MinLength<4>; +// Set individual values +rootModule.configure({ + debug: true, + apiUrl: 'https://staging-api.example.com' +}); + +// Set nested values +rootModule.configure({ + database: { + host: 'production-db.example.com', + port: 5432, + password: 'secure-password', + ssl: true + } +}); +``` + +## Required Configuration + +Use the `!` operator to mark configuration as required: + +```typescript +class SecurityConfig { + jwtSecret!: string; // Required + encryptionKey!: string; // Required + sessionTimeout: number = 3600; // Optional with default } ``` -## Injection +If required configuration is not provided, the injector will throw an error at startup. + +## Configuration Validation -Configuration options, like other dependencies, can be safely and easily injected through the DI container as shown earlier. The simplest method is to reference a single option using the index access operator: +Deepkit's configuration system integrates with the type system to provide validation. You can use validation decorators and types to ensure configuration values meet your requirements: ```typescript -class WebsiteController { - constructor(private debug: RootConfiguration['debug']) {} +import { MinLength, MaxLength, Positive, Email } from '@deepkit/type'; - home() { - if (this.debug) console.debug('visit home page'); - } +class ServerConfig { + // String validation + host!: string & MinLength<1>; + + // Number validation + port!: number & Positive; + + // Email validation + adminEmail!: string & Email; + + // Complex validation + apiKey!: string & MinLength<32> & MaxLength<64>; } ``` -Configuration options can be referenced not only individually, but also as a group. The TypeScript utility type `Partial` is used for this purpose: +## Environment-based Configuration + +Load configuration from environment variables or files: ```typescript -class WebsiteController { - constructor(private options: Partial) {} +class AppConfig { + port: number = 3000; + database: { + host: string; + port: number; + } = { + host: 'localhost', + port: 5432 + }; +} - home() { - if (this.options.debug) console.debug('visit home page'); +// Load from environment +const config = { + port: parseInt(process.env.PORT || '3000'), + database: { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432') } +}; + +rootModule.configure(config); +``` + +## Configuration Inheritance + +Child modules inherit configuration from parent modules: + +```typescript +class GlobalConfig { + debug: boolean = false; + apiUrl: string = 'https://api.example.com'; +} + +class DatabaseConfig { + host: string = 'localhost'; + port: number = 5432; +} + +// Parent module +const rootModule = new InjectorModule([]) + .setConfigDefinition(GlobalConfig); + +// Child module inherits GlobalConfig and adds DatabaseConfig +const databaseModule = new InjectorModule([DatabaseService]) + .setConfigDefinition(DatabaseConfig) + .setParent(rootModule); + +// DatabaseService can inject from both configs +class DatabaseService { + constructor( + private debug: GlobalConfig['debug'], // From parent + private dbHost: DatabaseConfig['host'] // From current module + ) {} } ``` -To get all configuration options, the configuration class can also be referenced directly: +## Advanced Configuration Patterns + +### Configuration Factories + +Use factories for complex configuration logic: ```typescript -class WebsiteController { - constructor(private options: RootConfiguration) {} +class RedisConfig { + url!: string; + maxRetries: number = 3; +} - home() { - if (this.options.debug) console.debug('visit home page'); +const redisModule = new InjectorModule([ + { + provide: 'redis.client', + useFactory: (config: RedisConfig) => { + return new Redis(config.url, { + retryDelayOnFailover: 100, + maxRetriesPerRequest: config.maxRetries + }); + } } +]).setConfigDefinition(RedisConfig); +``` + +### Configuration Splitting + +Split large configurations into focused, reusable pieces: + +```typescript +class DatabaseConfig { + host: string = 'localhost'; + port: number = 5432; + ssl: boolean = false; +} + +class CacheConfig { + ttl: number = 3600; + maxSize: number = 1000; +} + +class AppConfig { + debug: boolean = false; + database: DatabaseConfig = new DatabaseConfig(); + cache: CacheConfig = new CacheConfig(); +} + +// Services can inject exactly what they need +class CacheService { + constructor(private config: CacheConfig) {} +} + +class DatabaseService { + constructor(private config: DatabaseConfig) {} } ``` -However, it is recommended to reference only the configuration options that are actually used. This not only simplifies unit tests, but also makes it easier to see what is actually needed from the code. +## Best Practices + +1. **Inject Only What You Need**: Use property access (`Config['property']`) rather than full config objects +2. **Use Defaults**: Provide sensible defaults for optional configuration +3. **Validate Early**: Use type validation to catch configuration errors at startup +4. **Environment Separation**: Use different configuration for development, staging, and production +5. **Documentation**: Document configuration options and their effects + +```typescript +// Good: Specific injection +class EmailService { + constructor(private smtpHost: EmailConfig['smtpHost']) {} +} + +// Less ideal: Full config injection +class EmailService { + constructor(private config: EmailConfig) {} +} +``` diff --git a/website/src/pages/documentation/dependency-injection/debugging.md b/website/src/pages/documentation/dependency-injection/debugging.md new file mode 100644 index 000000000..09de92079 --- /dev/null +++ b/website/src/pages/documentation/dependency-injection/debugging.md @@ -0,0 +1,341 @@ +# Error Handling and Debugging + +Deepkit's dependency injection system provides comprehensive error detection and helpful debugging information to help you identify and resolve issues quickly. + +## Common Errors + +### Undefined Dependencies + +When a dependency cannot be resolved, Deepkit provides detailed error messages: + +```typescript +class Database { + constructor(private config: DatabaseConfig) {} // DatabaseConfig not provided +} + +class UserService { + constructor(private db: Database) {} +} + +const injector = Injector.from([UserService, Database]); +// Error: Undefined dependency "config: DatabaseConfig" of Database(?) +``` + +**Solution**: Ensure all dependencies are provided: + +```typescript +const injector = Injector.from([ + UserService, + Database, + DatabaseConfig // Add missing dependency +]); +``` + +### Circular Dependencies + +Deepkit automatically detects circular dependencies: + +```typescript +class ServiceA { + constructor(private serviceB: ServiceB) {} +} + +class ServiceB { + constructor(private serviceA: ServiceA) {} +} + +const injector = Injector.from([ServiceA, ServiceB]); +// Error: Circular dependency found ServiceA -> ServiceB -> ServiceA +``` + +**Solutions**: + +1. **Redesign**: Extract shared logic into a third service +2. **Property Injection**: Break the cycle with property injection +3. **Factory Pattern**: Use a factory to defer instantiation + +```typescript +// Solution 1: Extract shared logic +class SharedService {} + +class ServiceA { + constructor(private shared: SharedService) {} +} + +class ServiceB { + constructor(private shared: SharedService) {} +} + +// Solution 2: Property injection +class ServiceA { + private serviceB!: Inject; + + constructor() {} +} + +class ServiceB { + constructor(private serviceA: ServiceA) {} +} +``` + +### Scope Errors + +Attempting to access scoped providers from the wrong scope: + +```typescript +class UserSession {} + +const injector = InjectorContext.forProviders([ + { provide: UserSession, scope: 'http' } +]); + +// Error: Service 'UserSession' is known but is not available in scope global +const session = injector.get(UserSession); +``` + +**Solution**: Use the correct scope: + +```typescript +const httpScope = injector.createChildScope('http'); +const session = httpScope.get(UserSession); // Works +``` + +### Type Errors + +When TypeScript types don't match runtime expectations: + +```typescript +class Service { + constructor(private value: any) {} // 'any' type is problematic +} + +const injector = Injector.from([Service]); +// Error: Undefined dependency "value: any" of Service(?) +``` + +**Solution**: Use specific types or provide explicit values: + +```typescript +class Service { + constructor(private value: string) {} +} + +const injector = Injector.from([ + Service, + { provide: 'string', useValue: 'hello' } +]); +``` + +## Debugging Techniques + +### Inspecting the Dependency Graph + +Use the injector's built-in methods to understand the dependency structure: + +```typescript +const injector = Injector.from([UserService, Database, Logger]); + +// Check if a provider is available +console.log(injector.has(UserService)); // true +console.log(injector.has(UnknownService)); // false + +// Get provider information +const userService = injector.get(UserService); +console.log(userService.constructor.name); // 'UserService' +``` + +### Module Inspection + +For complex module hierarchies, inspect module structure: + +```typescript +const rootModule = new InjectorModule([UserService]); +const dbModule = new InjectorModule([Database]).setParent(rootModule); + +// Check if a module provides a service +console.log(rootModule.isProvided(UserService)); // true +console.log(dbModule.isProvided(Database)); // true + +// Check exports +console.log(dbModule.isExported(Database)); // false (not exported) +``` + +### Debugging Scopes + +When working with scopes, verify scope configuration: + +```typescript +const injector = InjectorContext.forProviders([ + { provide: UserSession, scope: 'http' }, + { provide: Logger, scope: 'http' } +]); + +// Create scope and inspect +const scope = injector.createChildScope('http'); +console.log(scope.scope.name); // 'http' + +// Check what's available in scope +const session = scope.get(UserSession); +console.log(session instanceof UserSession); // true +``` + +## Error Prevention + +### Use TypeScript Strictly + +Enable strict TypeScript settings to catch issues early: + +```json +// tsconfig.json +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true + } +} +``` + +### Explicit Provider Configuration + +Be explicit about provider configuration to avoid ambiguity: + +```typescript +// Good: Explicit configuration +const injector = Injector.from([ + { provide: UserService, useClass: UserService }, + { provide: Database, useClass: PostgresDatabase }, + { provide: 'api.key', useValue: process.env.API_KEY } +]); + +// Less clear: Implicit configuration +const injector = Injector.from([UserService, Database]); +``` + +### Validate Configuration Early + +Use configuration validation to catch errors at startup: + +```typescript +class AppConfig { + port!: number & Positive; + host!: string & MinLength<1>; +} + +// This will throw if configuration is invalid +const module = new InjectorModule([]) + .setConfigDefinition(AppConfig) + .configure({ + port: -1, // Invalid: not positive + host: '' // Invalid: too short + }); +``` + +## Testing and Debugging + +### Unit Testing with Mocks + +Create test-specific injectors with mocked dependencies: + +```typescript +// Production +class UserService { + constructor(private db: Database) {} +} + +// Test +class MockDatabase { + findUser() { return { id: 1, name: 'Test User' }; } +} + +const testInjector = Injector.from([ + { provide: Database, useClass: MockDatabase }, + UserService +]); + +const userService = testInjector.get(UserService); +// userService now uses MockDatabase +``` + +### Integration Testing + +Test module integration: + +```typescript +const testModule = new InjectorModule([ + UserService, + { provide: Database, useValue: mockDatabase }, + { provide: Logger, useValue: mockLogger } +]); + +const injector = new InjectorContext(testModule); +const userService = injector.get(UserService); + +// Test that all dependencies are properly injected +expect(userService).toBeDefined(); +``` + +## Performance Debugging + +### Identifying Expensive Dependencies + +Monitor dependency creation performance: + +```typescript +class ExpensiveService { + constructor() { + console.time('ExpensiveService creation'); + // Expensive initialization + console.timeEnd('ExpensiveService creation'); + } +} + +// Use transient if you suspect singleton caching issues +const injector = Injector.from([ + { provide: ExpensiveService, transient: true } +]); +``` + +### Memory Leak Detection + +Monitor scope cleanup: + +```typescript +const injector = InjectorContext.forProviders([ + { provide: UserSession, scope: 'http' } +]); + +// Create many scopes +for (let i = 0; i < 1000; i++) { + const scope = injector.createChildScope('http'); + const session = scope.get(UserSession); + // Scope should be garbage collected when it goes out of scope +} +``` + +## Best Practices for Debugging + +1. **Use Descriptive Names**: Clear class and property names help with error messages +2. **Fail Fast**: Validate dependencies at startup, not at runtime +3. **Log Dependency Creation**: Add logging to constructors during debugging +4. **Test Module Boundaries**: Ensure imports/exports work as expected +5. **Use Development Tools**: Leverage TypeScript's compiler and IDE features + +```typescript +// Good: Clear, debuggable code +class UserNotificationService { + constructor( + private emailService: EmailService, + private smsService: SmsService, + private logger: Logger + ) { + this.logger.log('UserNotificationService initialized'); + } +} + +// Less ideal: Unclear dependencies +class Service { + constructor(private a: any, private b: any) {} +} +``` diff --git a/website/src/pages/documentation/dependency-injection/getting-started.md b/website/src/pages/documentation/dependency-injection/getting-started.md index 2b25dbfd6..fe56ea7d4 100644 --- a/website/src/pages/documentation/dependency-injection/getting-started.md +++ b/website/src/pages/documentation/dependency-injection/getting-started.md @@ -1,84 +1,162 @@ # Getting Started -Since Dependency Injection in Deepkit is based on Runtime Types, it is necessary to have Runtime Types already installed correctly. See [Runtime Type](../runtime-types/getting-started.md). +Deepkit's dependency injection system is a high-performance, compile-time optimized container that provides automatic dependency resolution based on TypeScript types. Since Dependency Injection in Deepkit is based on Runtime Types, it is necessary to have Runtime Types already installed correctly. See [Runtime Type](../runtime-types/getting-started.md). + +## Key Features + +- **Type-safe**: Automatic dependency inferring based on TypeScript types +- **High Performance**: Compiling dependency injection with minimal runtime overhead +- **Flexible Providers**: Support for classes, factories, values, and existing providers +- **Scoped Injection**: Request, RPC, and custom scopes for lifecycle management +- **Configuration System**: Type-safe configuration injection with validation +- **Circular Dependency Detection**: Automatic detection and helpful error messages +- **Tagged Providers**: Group related services with tags + +## Installation If this is done successfully, `@deepkit/injector` can be installed or the Deepkit framework which already uses the library under the hood. ```sh - npm install @deepkit/injector +npm install @deepkit/injector ``` Once the library is installed, the API of it can be used directly. - ## Usage -To use Dependency Injection now, there are three ways. +To use Dependency Injection now, there are three ways, each suited for different complexity levels: -* Injector API (Low Level) -* Module API -* App API (Deepkit Framework) +* **Injector API** (Low Level) - Simple, single container +* **Module API** (Medium Level) - Multiple modules with imports/exports +* **App API** (Deepkit Framework) - Full framework integration If `@deepkit/injector` is to be used without the Deepkit Framework, the first two variants are recommended. ### Injector API -The Injector API has already been introduced in the [introduction to Dependency Injection](../dependency-injection). It is characterized by a very simple usage by means of a single class `InjectorContext` that creates a single DI container and is particularly suitable for simpler applications without modules. +The Injector API provides a simple, low-level interface for dependency injection. It is characterized by a very simple usage by means of a single class `Injector` that creates a single DI container and is particularly suitable for simpler applications without modules. ```typescript -import { InjectorContext } from '@deepkit/injector'; +import { Injector } from '@deepkit/injector'; + +class Database { + connect() { /* ... */ } +} + +class UserRepository { + constructor(private db: Database) {} + + findUser(id: string) { + // Use this.db to query users + } +} + +class UserService { + constructor(private repository: UserRepository) {} -const injector = InjectorContext.forProviders([ + getUser(id: string) { + return this.repository.findUser(id); + } +} + +// Create injector with all dependencies +const injector = Injector.from([ + Database, UserRepository, - HttpClient, + UserService ]); -const repository = injector.get(UserRepository); +// Get fully constructed service with all dependencies injected +const userService = injector.get(UserService); ``` -The `injector` object in this case is the dependency injection container. The function `InjectorContext.forProviders` takes an array of providers. See the section [Dependency Injection Providers](dependency-injection.md#di-providers) to learn which values can be passed. +The `injector` object in this case is the dependency injection container. The function `Injector.from` takes an array of providers. Dependencies are automatically resolved based on constructor parameter types. + +**When to use**: Simple applications, testing, or when you need a lightweight DI solution without module complexity. ### Module API -A more complex API is the `InjectorModule` class, which allows to store the providers in different modules to create multiple encapsulated DI containers per module. Also this allows using configuration classes per module, which makes it easier to provide configuration values automatically validated to the providers. Modules can import themselves among themselves, providers export, in order to build up a hierarchy and nicely separated architecture. +The `InjectorModule` API provides a more sophisticated approach for larger applications. It allows you to organize providers into separate modules, each with their own encapsulated scope, configuration, and import/export relationships. This creates a clean, hierarchical architecture that mirrors your application structure. -This API should be used if the application is more complex and the Deepkit Framework is not used. +**Key Benefits:** +- **Encapsulation**: Each module has its own provider scope +- **Configuration**: Type-safe configuration per module +- **Imports/Exports**: Control which providers are shared between modules +- **Hierarchy**: Build complex application architectures ```typescript import { InjectorModule, InjectorContext } from '@deepkit/injector'; -const lowLevelModule = new InjectorModule([HttpClient]) - .addExport(HttpClient); +// Database module - provides low-level database access +class DatabaseConfig { + host: string = 'localhost'; + port: number = 5432; +} + +class Database { + constructor(private config: DatabaseConfig) {} + connect() { /* ... */ } +} + +const databaseModule = new InjectorModule([Database]) + .setConfigDefinition(DatabaseConfig) + .addExport(Database); // Export Database to parent modules -const rootModule = new InjectorModule([UserRepository]) - .addImport(lowLevelModule); +// User module - provides user-related services +class UserRepository { + constructor(private db: Database) {} // Imported from databaseModule +} + +class UserService { + constructor(private repository: UserRepository) {} +} + +const userModule = new InjectorModule([UserRepository, UserService]) + .addImport(databaseModule) + .addExport(UserService); // Only export UserService, keep UserRepository internal + +// Root module - combines all modules +const rootModule = new InjectorModule([]) + .addImport(userModule); + +// Configure the database +databaseModule.configure({ host: 'production-db.example.com', port: 5432 }); const injector = new InjectorContext(rootModule); +const userService = injector.get(UserService); ``` -The `injector` object in this case is the dependency injection container. Providers can be split into different modules and then imported again in different places using module imports. This creates a natural hierarchy that reflects the hierarchy of the application or architecture. -The InjectorContext should always be given the top module in the hierarchy, also called root module or app module. The InjectorContext then only has an intermediary role: calls to `injector.get()` are simply forwarded to the root module. However, it is also possible to get providers from non-root modules by passing the module as a second argument. +**Module Encapsulation**: All non-root modules are encapsulated by default. Providers in a module are only available within that module unless explicitly exported. ```typescript -const repository = injector.get(UserRepository); +// Get from root module (works - UserService is exported) +const userService = injector.get(UserService); -const httpClient = injector.get(HttpClient, lowLevelModule); -``` +// Get from specific module (works - accessing internal provider) +const repository = injector.get(UserRepository, userModule); -All non-root modules are encapsulated by default, so that all providers in this module are only available to itself. If a provider is to be available to other modules, this provider must be exported. By exporting, the provider moves to the parent module of the hierarchy and can be used that way. +// This would fail - UserRepository is not exported to root +// const repository = injector.get(UserRepository); // Error! +``` -To export all providers by default to the top level, the root module, the option `forRoot` can be used. This allows all providers to be used by all other modules. +**Root Modules**: Use `forRoot()` to export all providers to the root level automatically: ```typescript -const lowLevelModule = new InjectorModule([HttpClient]) - .forRoot(); //export all Providers to the root +const sharedModule = new InjectorModule([HttpClient, Logger]) + .forRoot(); // All providers available globally ``` -### App API +**When to use**: Medium to large applications that need modular architecture, configuration management, or when building reusable modules. -Once the Deepkit framework is used, modules are defined with the `@deepkit/app` API. This is based on the Module API, so the capabilities from there are also available. In addition, it is possible to work with powerful hooks and define configuration loaders to map even more dynamic architectures. +### App API (Deepkit Framework) -The [Framework Modules](../app/modules.md) chapter describes this in more detail. +The App API is the highest-level interface, providing full framework integration with the Deepkit Framework. It builds upon the Module API but adds powerful features like hooks, configuration loaders, and automatic HTTP/RPC integration. + +**Additional Features:** +- **Automatic HTTP/RPC Integration**: Controllers and routes with dependency injection +- **Lifecycle Hooks**: onBootstrap, onShutdown, etc. +- **Configuration Loaders**: Load config from files, environment variables +- **Built-in Modules**: HTTP, Database, Validation, etc. ```typescript import { App } from '@deepkit/app'; @@ -86,25 +164,71 @@ import { FrameworkModule } from '@deepkit/framework'; import { HttpRouterRegistry, HttpBody } from '@deepkit/http'; interface User { + id: number; username: string; + email: string; } -class Service { - users: User[] = []; +class UserRepository { + private users: User[] = []; + + create(user: Omit): User { + const newUser = { ...user, id: Date.now() }; + this.users.push(newUser); + return newUser; + } + + findAll(): User[] { + return this.users; + } +} + +class UserService { + constructor(private repository: UserRepository) {} + + async createUser(userData: Omit): Promise { + // Business logic here + return this.repository.create(userData); + } + + async getUsers(): Promise { + return this.repository.findAll(); + } } const app = new App({ - providers: [Service], + providers: [UserRepository, UserService], imports: [new FrameworkModule()], }); +// Dependency injection works automatically in HTTP routes const router = app.get(HttpRouterRegistry); -router.post('/users', (body: HttpBody, service: Service) => { - service.users.push(body); +router.post('/users', async (body: HttpBody>, service: UserService) => { + return await service.createUser(body); }); -router.get('/users', (service: Service): Users => { - return service.users; +router.get('/users', async (service: UserService) => { + return await service.getUsers(); }); + +app.run(); ``` + +**When to use**: Full applications using the Deepkit Framework that need HTTP servers, databases, validation, and other framework features. + +## Performance Considerations + +Deepkit's injector is designed for high performance: + +- **Compile-time optimization**: Dependency graphs are analyzed and optimized at build time +- **Minimal runtime overhead**: No reflection or metadata lookup during injection +- **Singleton by default**: Instances are cached to avoid repeated construction +- **Lazy instantiation**: Services are only created when first requested + +## Next Steps + +- Learn about different [Provider Types](./providers.md) +- Understand [Injection Patterns](./injection.md) +- Explore [Scopes](./scopes.md) for request-based lifecycles +- Set up [Configuration](./configuration.md) for your modules diff --git a/website/src/pages/documentation/dependency-injection/injection.md b/website/src/pages/documentation/dependency-injection/injection.md index eb5a6d795..4dc408317 100644 --- a/website/src/pages/documentation/dependency-injection/injection.md +++ b/website/src/pages/documentation/dependency-injection/injection.md @@ -1,71 +1,276 @@ # Injection -It's called Dependency Injection since a dependency is injected. Injection either happens by the user (manually) or by the DI container (automatically). +Dependency Injection is the process of providing dependencies to a class or function automatically. Deepkit's injector analyzes TypeScript types at compile time to determine what dependencies are needed and resolves them automatically. ## Constructor Injection -In most cases, constructor injection is used. All dependencies are specified as constructor arguments and are automatically injected by the DI container. +Constructor injection is the most common and recommended pattern. Dependencies are declared as constructor parameters and automatically injected when the class is instantiated. ```typescript -class MyService { - constructor(protected database: Database) { +class Database { + connect() { /* ... */ } +} + +class Logger { + log(message: string) { /* ... */ } +} + +class UserService { + constructor( + private database: Database, + private logger: Logger + ) {} + + async getUser(id: string) { + this.logger.log(`Fetching user ${id}`); + // Use this.database to fetch user } } + +// All dependencies are automatically resolved +const injector = Injector.from([Database, Logger, UserService]); +const userService = injector.get(UserService); ``` -Optional dependencies should be marked as such, otherwise an error could be triggered if no provider can be found. +**Optional Dependencies**: Mark dependencies as optional to avoid errors when providers are not available: ```typescript class MyService { - constructor(protected database?: Database) { + constructor( + private database: Database, + private cache?: CacheService // Optional - won't throw if not provided + ) {} + + getData(key: string) { + if (this.cache) { + return this.cache.get(key) || this.database.get(key); + } + return this.database.get(key); } } ``` ## Property Injection -An alternative to constructor injection is property injection. This is usually used when the dependency is optional or the constructor is otherwise too full. The properties are automatically assigned once the instance is created (and thus the constructor is executed). +Property injection provides an alternative to constructor injection. It's useful when you have optional dependencies, want to avoid constructor parameter bloat, or need to inject dependencies after construction. Properties are automatically assigned after the instance is created. ```typescript import { Inject } from '@deepkit/core'; class MyService { - //required + // Required property injection protected database!: Inject; - //or optional - protected database?: Inject; + // Optional property injection + protected cache?: Inject; + + // You can also inject with tokens + protected apiKey!: Inject; + + constructor(private logger: Logger) { + // Constructor injection can be mixed with property injection + } + + async getData(id: string) { + // Properties are available after construction + this.logger.log(`Fetching data for ${id}`); + + if (this.cache) { + const cached = await this.cache.get(id); + if (cached) return cached; + } + + const data = await this.database.get(id); + this.cache?.set(id, data); + return data; + } } + +// Setup providers including token-based injection +const injector = Injector.from([ + Database, + Logger, + CacheService, + MyService, + { provide: 'api.key', useValue: 'secret-key-123' } +]); ``` +**When to use Property Injection:** +- Optional dependencies that may not always be available +- Breaking circular dependencies (though this should be rare) +- Reducing constructor parameter count +- Late binding scenarios + ## Parameter Injection -In various places you can define a callback function, like for example for HTTP Routes or CLI commands. In this case you can define dependencies as parameters. -They will be automatically injected by the DI container. +Parameter injection allows you to inject dependencies directly into function parameters. This is particularly useful for HTTP routes, CLI commands, event handlers, and other callback functions where you don't control the instantiation. ```typescript import { Database } from './db'; +import { Logger } from './logger'; -app.get('/', (database: Database) => { - //... +// HTTP route with injected dependencies +app.get('/users/:id', ( + id: string, // Route parameter + database: Database, // Injected dependency + logger: Logger, // Another injected dependency + request: HttpRequest // Framework-provided dependency +) => { + logger.log(`Fetching user ${id}`); + return database.findUser(id); +}); + +// CLI command with injection +app.command('migrate', ( + database: Database, + logger: Logger, + config: AppConfig['database'] // Configuration injection +) => { + logger.log('Starting migration...'); + database.migrate(); +}); + +// Event handler with injection +eventBus.on('user.created', ( + event: UserCreatedEvent, // Event data + emailService: EmailService, // Injected service + logger: Logger // Injected logger +) => { + logger.log(`Sending welcome email to ${event.user.email}`); + emailService.sendWelcomeEmail(event.user); }); ``` -## Injector Context +**Mixed Parameters**: You can mix regular parameters with injected dependencies. The injector automatically distinguishes between them based on type information. -In case you want to resolve dependencies dynamically, you can inject `InjectorContext` and use it to retrieve dependencies. +## Dynamic Resolution with InjectorContext + +Sometimes you need to resolve dependencies dynamically at runtime rather than through static injection. The `InjectorContext` provides programmatic access to the dependency injection container. ```typescript import { InjectorContext } from '@deepkit/injector'; -class MyService { - constructor(protected context: InjectorContext) { +class ServiceFactory { + constructor(private context: InjectorContext) {} + + createService(type: 'user' | 'product'): any { + switch (type) { + case 'user': + return this.context.get(UserService); + case 'product': + return this.context.get(ProductService); + default: + throw new Error(`Unknown service type: ${type}`); + } + } +} + +class PluginManager { + constructor(private context: InjectorContext) {} + + loadPlugin(pluginClass: ClassType) { + // Dynamically resolve plugin with its dependencies + return this.context.get(pluginClass); + } +} +``` + +**Scoped Resolution**: This is especially useful when working with [Dependency Injection Scopes](./scopes.md): + +```typescript +class RequestHandler { + constructor(private context: InjectorContext) {} + + async handleRequest(request: HttpRequest) { + // Create a request scope + const requestScope = this.context.createChildScope('http'); + + // Set request-specific values + requestScope.set(HttpRequest, request); + + // Get request-scoped services + const userSession = requestScope.get(UserSession); + const controller = requestScope.get(ApiController); + + return controller.handle(); } +} +``` + +## Circular Dependency Detection + +Deepkit automatically detects circular dependencies and provides helpful error messages: + +```typescript +class ServiceA { + constructor(private serviceB: ServiceB) {} +} + +class ServiceB { + constructor(private serviceA: ServiceA) {} +} + +// This will throw a CircularDependencyError +const injector = Injector.from([ServiceA, ServiceB]); +// Error: Circular dependency found ServiceA -> ServiceB -> ServiceA +``` + +**Resolving Circular Dependencies:** +1. **Redesign**: Often indicates a design issue - consider extracting shared logic +2. **Property Injection**: Use property injection for one of the dependencies +3. **Factory Pattern**: Use a factory to break the cycle +4. **Event-Driven**: Use events instead of direct dependencies + +## Advanced Injection Patterns + +### Partial Factory + +Create instances with some dependencies injected and others provided manually: - getDatabase(): Database { - return this.context.get(Database); +```typescript +import { PartialFactory } from '@deepkit/injector'; + +class ReportGenerator { + constructor( + private database: Database, // Injected + private template: string, // Manual + private options: ReportOptions // Manual + ) {} +} + +class ReportService { + constructor(private factory: PartialFactory) {} + + generateReport(template: string, options: ReportOptions) { + // Database is injected, template and options are provided manually + const generator = this.factory({ template, options }); + return generator.generate(); } } ``` -This is especially useful when working with [Dependency Injection Scopes](./scopes.md). +### Transient Injection Target + +Access information about what is requesting the injection: + +```typescript +import { TransientInjectionTarget } from '@deepkit/injector'; + +class Logger { + constructor(private target: TransientInjectionTarget) {} + + log(message: string) { + const className = this.target.token.name || 'Unknown'; + console.log(`[${className}] ${message}`); + } +} + +class UserService { + constructor(private logger: Logger) {} + + getUser() { + this.logger.log('Getting user'); // Logs: [UserService] Getting user + } +} +``` diff --git a/website/src/pages/documentation/dependency-injection/providers.md b/website/src/pages/documentation/dependency-injection/providers.md index 5c145b295..d7b7cc6bd 100644 --- a/website/src/pages/documentation/dependency-injection/providers.md +++ b/website/src/pages/documentation/dependency-injection/providers.md @@ -1,54 +1,111 @@ # Providers -There are several ways to provide dependencies in the Dependency Injection container. The simplest variant is simply the specification of a class. This is also known as short ClassProvider. +Providers define how dependencies are created and managed by the dependency injection container. They specify what should be injected when a particular type or token is requested. Deepkit supports several provider types, each suited for different use cases. + +## Provider Lifecycle + +**Singleton (Default)**: By default, all providers are singletons - only one instance exists during the application lifetime: ```typescript new App({ - providers: [UserRepository] + providers: [UserRepository] // Singleton by default }); -``` -This represents a special provider, since only the class is specified. All other providers must be specified as object literals. +// Both calls return the same instance +const repo1 = injector.get(UserRepository); +const repo2 = injector.get(UserRepository); +console.log(repo1 === repo2); // true +``` -By default, all providers are marked as singletons, so only one instance exists at any given time. To create a new instance each time a provider is deployed, the `transient` option can be used. This will cause classes to be recreated each time or factories to be executed each time. +**Transient**: Create a new instance every time the provider is requested: ```typescript new App({ - providers: [{ provide: UserRepository, transient: true }] + providers: [ + { provide: UserRepository, transient: true } + ] }); + +// Each call creates a new instance +const repo1 = injector.get(UserRepository); +const repo2 = injector.get(UserRepository); +console.log(repo1 === repo2); // false ``` +**When to use Transient:** +- Stateful services that shouldn't be shared +- Services that are expensive to keep in memory +- Services that need fresh state for each use + ## ClassProvider -Besides the short ClassProvider there is also the regular ClassProvider, which is an object literal instead of a class. +ClassProvider is the most common provider type. It tells the injector to create an instance of a class when the provider is requested. + +### Short Form (Recommended) + +The simplest way to register a class: ```typescript new App({ - providers: [{ provide: UserRepository, useClass: UserRepository }] + providers: [UserRepository] // Short form }); ``` -This is equivalent to these two: +### Explicit Form + +The explicit object form provides more control: ```typescript new App({ - providers: [{ provide: UserRepository }] + providers: [ + { provide: UserRepository, useClass: UserRepository } + ] }); +``` -new App({ - providers: [UserRepository] -}); +These are equivalent: + +```typescript +// All three are identical +new App({ providers: [UserRepository] }); +new App({ providers: [{ provide: UserRepository }] }); +new App({ providers: [{ provide: UserRepository, useClass: UserRepository }] }); ``` -It can be used to exchange a provider with another class. +### Class Substitution + +Use explicit form to substitute one class for another: ```typescript +interface UserRepository { + findUser(id: string): User | null; +} + +class DatabaseUserRepository implements UserRepository { + constructor(private db: Database) {} + findUser(id: string) { /* database implementation */ } +} + +class MockUserRepository implements UserRepository { + findUser(id: string) { /* mock implementation */ } +} + +// Production new App({ - providers: [{ provide: UserRepository, useClass: OtherUserRepository }] + providers: [ + { provide: UserRepository, useClass: DatabaseUserRepository } + ] +}); + +// Testing +new App({ + providers: [ + { provide: UserRepository, useClass: MockUserRepository } + ] }); ``` -In this example, the `OtherUserRepository` class is now also managed in the DI container and all its dependencies are resolved automatically. +The substitute class (`DatabaseUserRepository` or `MockUserRepository`) is fully managed by the DI container with automatic dependency resolution. ## ValueProvider @@ -254,6 +311,94 @@ rootModule.configureProvider((v, db: Database) => { }); ``` +## TagProvider + +TagProviders allow you to group related services together and inject them as a collection. This is useful for plugin systems, event handlers, middleware, or any scenario where you need to collect multiple implementations of a common interface. + +```typescript +import { Tag } from '@deepkit/injector'; + +// Define what services should implement +interface EventHandler { + handle(event: any): void; +} + +// Create a tag class +class EventHandlerTag extends Tag {} + +// Implement various handlers +class UserEventHandler implements EventHandler { + handle(event: any) { + console.log('Handling user event:', event); + } +} + +class EmailEventHandler implements EventHandler { + handle(event: any) { + console.log('Sending email for event:', event); + } +} + +class LogEventHandler implements EventHandler { + handle(event: any) { + console.log('Logging event:', event); + } +} + +// Event manager that uses all handlers +class EventManager { + constructor(private handlers: EventHandlerTag) {} + + dispatch(event: any) { + // handlers.services contains all tagged services + for (const handler of this.handlers.services) { + handler.handle(event); + } + } +} + +// Register tagged providers +new App({ + providers: [ + EventManager, + EventHandlerTag.provide(UserEventHandler), + EventHandlerTag.provide(EmailEventHandler), + EventHandlerTag.provide(LogEventHandler), + ] +}); +``` + +**Advanced Tag Usage:** + +```typescript +// Tags can be used with different provider types +class PluginTag extends Tag {} + +new App({ + providers: [ + // Class provider + PluginTag.provide(DatabasePlugin), + + // Factory provider + PluginTag.provide({ + useFactory: (config: AppConfig) => new CachePlugin(config.cache) + }), + + // Value provider + PluginTag.provide({ + useValue: new StaticPlugin() + }), + ] +}); +``` + +**Use Cases for Tags:** +- Plugin systems +- Middleware collections +- Event handler registration +- Strategy pattern implementations +- Decorator pattern collections + ## Nominal types Note that the passed type to `configureProvider`, like in the last example `UserRepository`, is not resolved using structural type checking, but by nominal types. This means for example two classes/interfaces with the same structure but different identity are not compatible. The same is true for `get` calls or when a dependency is resolved. diff --git a/website/src/pages/documentation/dependency-injection/scopes.md b/website/src/pages/documentation/dependency-injection/scopes.md index 1ac5c40e9..6ddd339c2 100644 --- a/website/src/pages/documentation/dependency-injection/scopes.md +++ b/website/src/pages/documentation/dependency-injection/scopes.md @@ -1,54 +1,237 @@ # Scopes -By default, all providers of the DI container are singletons and are therefore instantiated only once. This means that in the example of UserRepository there is always only one instance of UserRepository during the entire runtime. At no time is a second instance created, unless the user does this manually with the "new" keyword. +Scopes control the lifecycle of service instances in the dependency injection container. By default, all providers are **singletons** - instantiated once and reused throughout the application lifetime. However, many real-world scenarios require different lifecycles. -However, there are various use cases where a provider should only be instantiated for a short time or only during a certain event. Such an event could be, for example, an HTTP request or an RPC call. This would mean that a new instance is created for each event and after this instance is no longer used it is automatically removed (by the garbage collector). +## Why Scopes Matter -An HTTP request is a classic example of a scope. For example, providers such as a session, a user object, or other request-related providers can be registered to this scope. To create a scope, simply choose an arbitrary scope name and then specify it with the providers. +Consider these scenarios: +- **HTTP Request**: User session, request context, and request-specific data should be isolated per request +- **RPC Call**: Each remote procedure call should have its own context and state +- **CLI Command**: Command-specific configuration and state +- **Database Transaction**: Services that need to share a database transaction + +Scopes provide a way to create isolated dependency injection contexts for these scenarios. + +## Scope Basics + +### Global Scope (Default) + +All providers without a scope specification live in the global scope: + +```typescript +class Database { + connect() { /* ... */ } +} + +class UserRepository { + constructor(private db: Database) {} +} + +// Both are global singletons +const injector = Injector.from([Database, UserRepository]); + +const repo1 = injector.get(UserRepository); +const repo2 = injector.get(UserRepository); +console.log(repo1 === repo2); // true - same instance +``` + +### Scoped Providers + +To create a scoped provider, specify the scope name: ```typescript import { InjectorContext } from '@deepkit/injector'; -class UserSession {} +class UserSession { + constructor(public userId?: string) {} +} + +class RequestLogger { + private logs: string[] = []; + + log(message: string) { + this.logs.push(`${new Date().toISOString()}: ${message}`); + } + + getLogs() { + return this.logs; + } +} const injector = InjectorContext.forProviders([ - {provide: UserSession, scope: 'http'} + { provide: UserSession, scope: 'http' }, + { provide: RequestLogger, scope: 'http' } ]); ``` -Once a scope is specified, this provider cannot be obtained directly from the DI container, so the following call will fail: +**Important**: Once a provider is scoped, it cannot be obtained directly from the global injector: ```typescript -const session = injector.get(UserSession); //throws +// This will throw an error +const session = injector.get(UserSession); +// Error: Service 'UserSession' is known but is not available in scope global ``` -Instead, a scoped DI container must be created. This would happen every time an HTTP request comes in: +## Creating Scoped Containers + +To access scoped providers, create a child scope: ```typescript +// Create a new HTTP scope (typically done per request) const httpScope = injector.createChildScope('http'); + +// Now scoped providers are accessible +const session = httpScope.get(UserSession); +const logger = httpScope.get(RequestLogger); + +// Global providers are still accessible +const database = httpScope.get(Database); // Works if Database is global ``` -Providers that are also registered in this scope can now be requested on this scoped DI container, as well as all providers that have not defined a scope. +## Scope Isolation + +Each scoped container maintains its own instances: ```typescript -const session = httpScope.get(UserSession); //works +// Request 1 +const scope1 = injector.createChildScope('http'); +const session1 = scope1.get(UserSession); +const logger1 = scope1.get(RequestLogger); + +// Request 2 +const scope2 = injector.createChildScope('http'); +const session2 = scope2.get(UserSession); +const logger2 = scope2.get(RequestLogger); + +// Different instances per scope +console.log(session1 === session2); // false +console.log(logger1 === logger2); // false + +// But same instance within a scope +const session1Again = scope1.get(UserSession); +console.log(session1 === session1Again); // true ``` -Since all providers are singleton by default, each call to `get(UserSession)` will always return the same instance per scoped container. If you create multiple scoped containers, multiple UserSessions will be created. +## Dynamic Value Setting -Scoped DI containers have the ability to set values dynamically from the outside. For example, in an HTTP scope, it is easy to set the HttpRequest and HttpResponse objects. +Scoped containers can have values set dynamically from the outside. This is particularly useful for injecting request-specific data: ```typescript +class HttpRequest { + constructor(public url: string, public method: string) {} +} + +class HttpResponse { + constructor(public statusCode: number = 200) {} +} + const injector = InjectorContext.forProviders([ - {provide: HttpResponse, scope: 'http'}, - {provide: HttpRequest, scope: 'http'}, + { provide: HttpRequest, scope: 'http' }, + { provide: HttpResponse, scope: 'http' }, + { provide: UserSession, scope: 'http' } ]); +// Simulate HTTP server httpServer.on('request', (req, res) => { const httpScope = injector.createChildScope('http'); - httpScope.set(HttpRequest, req); - httpScope.set(HttpResponse, res); + + // Set request-specific values + httpScope.set(HttpRequest, new HttpRequest(req.url, req.method)); + httpScope.set(HttpResponse, new HttpResponse()); + + // Now any service can access these + const controller = httpScope.get(ApiController); + controller.handleRequest(); }); ``` -Applications using the Deepkit framework have by default an `http`, an `rpc`, and a `cli` scope. See respectively the chapter [CLI](../cli.md), [HTTP](../http.md), or [RPC](../rpc.md). +## Real-World Example: HTTP Request Handling + +```typescript +class UserSession { + constructor( + private request: HttpRequest, + private userRepository: UserRepository + ) {} + + async getCurrentUser() { + const token = this.request.headers.authorization; + return this.userRepository.findByToken(token); + } +} + +class ApiController { + constructor( + private session: UserSession, + private logger: RequestLogger + ) {} + + async getProfile() { + this.logger.log('Getting user profile'); + const user = await this.session.getCurrentUser(); + return { user }; + } +} + +// Setup +const injector = InjectorContext.forProviders([ + UserRepository, // Global + { provide: HttpRequest, scope: 'http' }, // Request-scoped + { provide: UserSession, scope: 'http' }, // Request-scoped + { provide: RequestLogger, scope: 'http' }, // Request-scoped + { provide: ApiController, scope: 'http' } // Request-scoped +]); + +// Per request +app.get('/profile', (req, res) => { + const scope = injector.createChildScope('http'); + scope.set(HttpRequest, req); + + const controller = scope.get(ApiController); + return controller.getProfile(); +}); +``` + +## Built-in Scopes in Deepkit Framework + +Applications using the Deepkit framework have these scopes by default: + +- **`http`**: HTTP request lifecycle - see [HTTP](../http.md) +- **`rpc`**: RPC call lifecycle - see [RPC](../rpc.md) +- **`cli`**: CLI command lifecycle - see [CLI](../cli.md) + +## Performance Considerations + +- **Scope Creation**: Creating scopes is lightweight but not free +- **Memory**: Scoped instances are garbage collected when the scope is released +- **Dependency Resolution**: Scoped resolution is slightly slower than global +- **Best Practice**: Use scopes judiciously - not every service needs to be scoped + +## Common Patterns + +### Request Context Pattern +```typescript +class RequestContext { + constructor( + public request: HttpRequest, + public user: User, + public traceId: string + ) {} +} + +// Set once per request, used by many services +scope.set(RequestContext, new RequestContext(req, user, traceId)); +``` + +### Transactional Services +```typescript +class TransactionalUserService { + constructor( + private transaction: DatabaseTransaction, + private userRepository: UserRepository + ) {} +} + +// Each request gets its own transaction +scope.set(DatabaseTransaction, db.beginTransaction()); +``` diff --git a/website/src/pages/documentation/dependency-injection/testing.md b/website/src/pages/documentation/dependency-injection/testing.md new file mode 100644 index 000000000..7b39ea72f --- /dev/null +++ b/website/src/pages/documentation/dependency-injection/testing.md @@ -0,0 +1,453 @@ +# Testing with Dependency Injection + +Dependency injection makes testing easier by allowing you to replace real dependencies with mocks, stubs, or test doubles. This guide shows how to effectively test applications using Deepkit's DI system. + +## Unit Testing Basics + +### Testing Services with Dependencies + +```typescript +// Service under test +class UserService { + constructor( + private repository: UserRepository, + private emailService: EmailService, + private logger: Logger + ) {} + + async createUser(userData: CreateUserData): Promise { + this.logger.log(`Creating user: ${userData.email}`); + + const user = await this.repository.create(userData); + await this.emailService.sendWelcomeEmail(user); + + this.logger.log(`User created: ${user.id}`); + return user; + } +} + +// Test with mocks +describe('UserService', () => { + let userService: UserService; + let mockRepository: jest.Mocked; + let mockEmailService: jest.Mocked; + let mockLogger: jest.Mocked; + + beforeEach(() => { + // Create mocks + mockRepository = { + create: jest.fn(), + findById: jest.fn(), + update: jest.fn(), + delete: jest.fn() + } as any; + + mockEmailService = { + sendWelcomeEmail: jest.fn(), + sendPasswordReset: jest.fn() + } as any; + + mockLogger = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn() + } as any; + + // Create test injector with mocks + const testInjector = Injector.from([ + { provide: UserRepository, useValue: mockRepository }, + { provide: EmailService, useValue: mockEmailService }, + { provide: Logger, useValue: mockLogger }, + UserService + ]); + + userService = testInjector.get(UserService); + }); + + it('should create user and send welcome email', async () => { + // Arrange + const userData = { email: 'test@example.com', name: 'Test User' }; + const createdUser = { id: '123', ...userData }; + + mockRepository.create.mockResolvedValue(createdUser); + mockEmailService.sendWelcomeEmail.mockResolvedValue(undefined); + + // Act + const result = await userService.createUser(userData); + + // Assert + expect(result).toEqual(createdUser); + expect(mockRepository.create).toHaveBeenCalledWith(userData); + expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(createdUser); + expect(mockLogger.log).toHaveBeenCalledWith('Creating user: test@example.com'); + expect(mockLogger.log).toHaveBeenCalledWith('User created: 123'); + }); +}); +``` + +### Testing with Test Modules + +For more complex scenarios, create dedicated test modules: + +```typescript +// Test module factory +function createTestModule(overrides: Partial<{ + userRepository: UserRepository; + emailService: EmailService; + logger: Logger; +}> = {}) { + return new InjectorModule([ + { + provide: UserRepository, + useValue: overrides.userRepository || createMockUserRepository() + }, + { + provide: EmailService, + useValue: overrides.emailService || createMockEmailService() + }, + { + provide: Logger, + useValue: overrides.logger || createMockLogger() + }, + UserService + ]); +} + +// Test with custom module +describe('UserService Integration', () => { + it('should handle repository errors gracefully', async () => { + const mockRepository = createMockUserRepository(); + mockRepository.create.mockRejectedValue(new Error('Database error')); + + const testModule = createTestModule({ userRepository: mockRepository }); + const injector = new InjectorContext(testModule); + const userService = injector.get(UserService); + + await expect(userService.createUser({ email: 'test@example.com' })) + .rejects.toThrow('Database error'); + }); +}); +``` + +## Integration Testing + +### Testing Module Integration + +Test how modules work together: + +```typescript +describe('User Module Integration', () => { + let injector: InjectorContext; + + beforeEach(() => { + const databaseModule = new InjectorModule([ + { provide: Database, useValue: createTestDatabase() } + ]).addExport(Database); + + const userModule = new InjectorModule([ + UserRepository, + UserService + ]).addImport(databaseModule); + + const rootModule = new InjectorModule([]) + .addImport(userModule); + + injector = new InjectorContext(rootModule); + }); + + it('should resolve all dependencies correctly', () => { + const userService = injector.get(UserService, userModule); + expect(userService).toBeInstanceOf(UserService); + + // Verify dependencies are injected + expect(userService['repository']).toBeInstanceOf(UserRepository); + }); +}); +``` + +### Testing Scoped Services + +Test services that use scopes: + +```typescript +describe('Scoped Services', () => { + let injector: InjectorContext; + + beforeEach(() => { + injector = InjectorContext.forProviders([ + { provide: UserSession, scope: 'http' }, + { provide: RequestLogger, scope: 'http' }, + Database // Global service + ]); + }); + + it('should create separate instances per scope', () => { + const scope1 = injector.createChildScope('http'); + const scope2 = injector.createChildScope('http'); + + const session1 = scope1.get(UserSession); + const session2 = scope2.get(UserSession); + + expect(session1).not.toBe(session2); + }); + + it('should share global services across scopes', () => { + const scope1 = injector.createChildScope('http'); + const scope2 = injector.createChildScope('http'); + + const db1 = scope1.get(Database); + const db2 = scope2.get(Database); + + expect(db1).toBe(db2); // Same instance + }); +}); +``` + +## Testing Patterns + +### Mock Factories + +Create reusable mock factories: + +```typescript +// Mock factory functions +function createMockUserRepository(): jest.Mocked { + return { + create: jest.fn(), + findById: jest.fn(), + findByEmail: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findAll: jest.fn() + } as any; +} + +function createMockEmailService(): jest.Mocked { + return { + sendWelcomeEmail: jest.fn().mockResolvedValue(undefined), + sendPasswordReset: jest.fn().mockResolvedValue(undefined), + sendNotification: jest.fn().mockResolvedValue(undefined) + } as any; +} + +// Test helper +class TestInjectorBuilder { + private providers: any[] = []; + + withMockUserRepository(mock?: Partial) { + const mockRepo = { ...createMockUserRepository(), ...mock }; + this.providers.push({ provide: UserRepository, useValue: mockRepo }); + return this; + } + + withMockEmailService(mock?: Partial) { + const mockEmail = { ...createMockEmailService(), ...mock }; + this.providers.push({ provide: EmailService, useValue: mockEmail }); + return this; + } + + withService(serviceClass: ClassType) { + this.providers.push(serviceClass); + return this; + } + + build(): Injector { + return Injector.from(this.providers); + } +} + +// Usage in tests +describe('UserService', () => { + it('should create user successfully', async () => { + const injector = new TestInjectorBuilder() + .withMockUserRepository({ + create: jest.fn().mockResolvedValue({ id: '123', email: 'test@example.com' }) + }) + .withMockEmailService() + .withService(UserService) + .build(); + + const userService = injector.get(UserService); + const result = await userService.createUser({ email: 'test@example.com' }); + + expect(result.id).toBe('123'); + }); +}); +``` + +### Testing Configuration + +Test services that depend on configuration: + +```typescript +class TestConfig { + apiUrl: string = 'http://test-api.example.com'; + debug: boolean = true; + timeout: number = 5000; +} + +describe('ConfigurableService', () => { + it('should use test configuration', () => { + const testModule = new InjectorModule([ConfigurableService]) + .setConfigDefinition(TestConfig) + .configure({ + apiUrl: 'http://mock-api.example.com', + timeout: 1000 + }); + + const injector = new InjectorContext(testModule); + const service = injector.get(ConfigurableService); + + // Verify service uses test configuration + expect(service.getApiUrl()).toBe('http://mock-api.example.com'); + }); +}); +``` + +### Testing Tagged Providers + +Test services that use tagged providers: + +```typescript +describe('Plugin System', () => { + it('should load all registered plugins', () => { + class TestPlugin1 implements Plugin { + name = 'test1'; + process(data: any) { return { ...data, test1: true }; } + } + + class TestPlugin2 implements Plugin { + name = 'test2'; + process(data: any) { return { ...data, test2: true }; } + } + + const injector = Injector.from([ + PluginManager, + PluginTag.provide(TestPlugin1), + PluginTag.provide(TestPlugin2) + ]); + + const manager = injector.get(PluginManager); + const result = manager.processData({ input: 'test' }); + + expect(result).toEqual({ + input: 'test', + test1: true, + test2: true + }); + }); +}); +``` + +## Testing Best Practices + +### 1. Isolate Units Under Test + +```typescript +// Good: Test only UserService behavior +const injector = Injector.from([ + { provide: UserRepository, useValue: mockRepository }, + { provide: EmailService, useValue: mockEmailService }, + UserService +]); + +// Avoid: Testing multiple real services together in unit tests +const injector = Injector.from([ + UserRepository, // Real implementation + EmailService, // Real implementation + UserService +]); +``` + +### 2. Use Descriptive Test Names + +```typescript +describe('UserService', () => { + describe('createUser', () => { + it('should create user and send welcome email when valid data provided', () => { + // Test implementation + }); + + it('should throw validation error when email is invalid', () => { + // Test implementation + }); + + it('should not send email when email service fails but still create user', () => { + // Test implementation + }); + }); +}); +``` + +### 3. Test Error Scenarios + +```typescript +it('should handle repository errors gracefully', async () => { + mockRepository.create.mockRejectedValue(new Error('Database connection failed')); + + await expect(userService.createUser(validUserData)) + .rejects.toThrow('Database connection failed'); + + // Verify email service was not called + expect(mockEmailService.sendWelcomeEmail).not.toHaveBeenCalled(); +}); +``` + +### 4. Verify Interactions + +```typescript +it('should log user creation process', async () => { + await userService.createUser(userData); + + expect(mockLogger.log).toHaveBeenCalledWith('Creating user: test@example.com'); + expect(mockLogger.log).toHaveBeenCalledWith('User created: 123'); + expect(mockLogger.log).toHaveBeenCalledTimes(2); +}); +``` + +### 5. Clean Up After Tests + +```typescript +afterEach(() => { + jest.clearAllMocks(); + // Clean up any global state +}); +``` + +## Testing Tools Integration + +### Jest Integration + +```typescript +// jest.config.js +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + setupFilesAfterEnv: ['/src/test-setup.ts'] +}; + +// test-setup.ts +import 'reflect-metadata'; + +// Global test utilities +global.createTestInjector = (providers: any[]) => { + return Injector.from(providers); +}; +``` + +### Vitest Integration + +```typescript +// vitest.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + setupFiles: ['./src/test-setup.ts'] + } +}); +``` + +By following these testing patterns and practices, you can build comprehensive test suites that verify your dependency injection setup works correctly and your services behave as expected. From 4165fea3ba54e768d73c298896b97efe8042a937 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 21:49:20 +0200 Subject: [PATCH 12/15] docs: improve rpc --- .../pages/documentation/rpc/collections.md | 65 +++- .../documentation/rpc/dependency-injection.md | 55 ++- website/src/pages/documentation/rpc/errors.md | 188 ++++++++- .../documentation/rpc/getting-started.md | 367 ++++++++++++++++-- .../src/pages/documentation/rpc/interfaces.md | 49 ++- .../pages/documentation/rpc/peer-to-peer.md | 107 ++++- .../documentation/rpc/progress-tracking.md | 83 +++- .../src/pages/documentation/rpc/security.md | 180 ++++++++- .../src/pages/documentation/rpc/streaming.md | 77 +++- .../src/pages/documentation/rpc/transport.md | 70 +++- 10 files changed, 1174 insertions(+), 67 deletions(-) diff --git a/website/src/pages/documentation/rpc/collections.md b/website/src/pages/documentation/rpc/collections.md index 2d5a3d180..b13c775b1 100644 --- a/website/src/pages/documentation/rpc/collections.md +++ b/website/src/pages/documentation/rpc/collections.md @@ -1,6 +1,69 @@ # Collections -Deepkit RPC provides a powerful `Collection` class for managing lists of entities with built-in state synchronization, pagination, and real-time updates. Collections are particularly useful for managing data sets that need to be kept in sync between client and server. +## Understanding RPC Collections + +Collections in Deepkit RPC solve a fundamental problem in distributed applications: how to efficiently manage and synchronize lists of data between client and server. Unlike simple arrays, Collections provide sophisticated state management, automatic synchronization, pagination support, and real-time updates while maintaining type safety and performance. + +### The Problem with Simple Arrays + +Traditional approaches to managing lists in RPC systems have several limitations: + +1. **Full Replacement**: Sending entire arrays for small changes is inefficient +2. **No State Tracking**: No way to know what changed between updates +3. **Memory Issues**: Large datasets can overwhelm client memory +4. **No Pagination**: Difficult to implement efficient pagination +5. **Synchronization Complexity**: Hard to keep client and server in sync +6. **Performance**: Network overhead grows linearly with data size + +### How Collections Solve These Problems + +Deepkit RPC Collections provide: + +- **Incremental Updates**: Only changed items are transmitted +- **State Management**: Track additions, modifications, and deletions +- **Pagination Support**: Built-in pagination with metadata +- **Memory Efficiency**: Load only what's needed +- **Real-time Sync**: Automatic synchronization with server state +- **Type Safety**: Full TypeScript support for collection items +- **Query Support**: Filtering, sorting, and searching capabilities + +### Collection Architecture + +``` +Server Side Network Client Side +┌─────────────┐ ┌─────────┐ ┌─────────────┐ +│ Collection │ incremental │ Change │ apply │ Collection │ +│ add(item) │ ────────────→ │ Events │ ─────────────→ │ get(id) │ +│ remove(id) │ │ │ │ all() │ +│ update(item)│ ← sync state ← │ State │ ← request ← │ count() │ +└─────────────┘ └─────────┘ └─────────────┘ +``` + +### Collection Lifecycle + +1. **Initialization**: Server creates collection with initial data +2. **Client Request**: Client requests collection through RPC action +3. **Initial Sync**: Server sends current state and metadata +4. **Live Updates**: Server pushes incremental changes to subscribed clients +5. **Client Operations**: Client can request modifications through RPC actions +6. **State Synchronization**: Changes are propagated to all connected clients + +### When to Use Collections + +Collections are ideal for: + +- **Data Tables**: User lists, product catalogs, order histories +- **Real-time Dashboards**: Live metrics, monitoring data +- **Collaborative Features**: Shared documents, team member lists +- **Chat Applications**: Message histories, user presence +- **Gaming**: Player lists, leaderboards, game state +- **Content Management**: Article lists, media galleries + +Avoid Collections for: +- **Large Binary Data**: Use streaming instead +- **Highly Volatile Data**: Consider direct streaming +- **Simple Key-Value Pairs**: Use regular objects +- **One-time Data**: Use simple RPC actions ## Basic Usage diff --git a/website/src/pages/documentation/rpc/dependency-injection.md b/website/src/pages/documentation/rpc/dependency-injection.md index 117b963ce..fd4c385b2 100644 --- a/website/src/pages/documentation/rpc/dependency-injection.md +++ b/website/src/pages/documentation/rpc/dependency-injection.md @@ -1,8 +1,59 @@ # Dependency Injection -Controller classes are managed by the Dependency Injection Container from `@deepkit/injector`. When using the Deepkit Framework, these controllers automatically have access to the providers of the modules that provide the controller. +## Understanding RPC Dependency Injection -In the Deepkit Framework, controllers are instantiated in the Dependency Injection Scope `rpc`, allowing all controllers to automatically access various providers from this scope. These additional providers are `HttpRequest` (optional), `RpcInjectorContext`, `SessionState`, `RpcKernelConnection`, and `ConnectionWriter`. +Dependency Injection (DI) in RPC systems is more complex than in traditional applications because RPC controllers exist in a distributed context where each client connection represents a separate execution scope. Deepkit RPC integrates seamlessly with Deepkit's powerful DI system to provide scoped, type-safe dependency management across network boundaries. + +### Why DI Matters in RPC + +RPC controllers need access to various services and resources: + +- **Business Logic Services**: User services, data repositories, external APIs +- **Connection-Specific Data**: Current user session, connection metadata +- **Shared Resources**: Database connections, caches, configuration +- **Request Context**: Authentication state, request tracing, logging context + +Without proper DI, you'd have to manually wire these dependencies, leading to: +- Tight coupling between controllers and services +- Difficulty testing controllers in isolation +- Complex initialization code +- Poor separation of concerns + +### RPC Scoping Model + +Deepkit RPC uses a sophisticated scoping model to manage dependencies: + +``` +Application Scope (Singleton) +├── Database Connections +├── Configuration Services +├── External API Clients +└── Shared Caches + +RPC Scope (Per Connection) +├── RpcKernelConnection +├── SessionState +├── RpcInjectorContext +├── ConnectionWriter +└── HttpRequest (optional) + +Request Scope (Per Action Call) +├── Action-specific context +├── Request tracing +└── Temporary resources +``` + +### Available RPC Providers + +When using the Deepkit Framework, RPC controllers automatically have access to these providers: + +| Provider | Scope | Description | +|----------|-------|-------------| +| `RpcKernelConnection` | RPC | Current client connection | +| `SessionState` | RPC | Authentication and session data | +| `RpcInjectorContext` | RPC | DI context for the current connection | +| `ConnectionWriter` | RPC | Low-level connection writing | +| `HttpRequest` | RPC | HTTP request (WebSocket upgrade) - optional | ```typescript import { RpcKernel, rpc } from '@deepkit/rpc'; diff --git a/website/src/pages/documentation/rpc/errors.md b/website/src/pages/documentation/rpc/errors.md index 07e3d8ed9..0a819fbf5 100644 --- a/website/src/pages/documentation/rpc/errors.md +++ b/website/src/pages/documentation/rpc/errors.md @@ -1,33 +1,181 @@ -# Errors +# Error Handling -Thrown errors are automatically forwarded to the client with all its information like the error message and also the stacktrace. +## Understanding RPC Error Handling -If nominal instances for the error object is import (because you use instanceof), it is required to use `@entity.name('@error:unique-name')` so that the given error class is registered in the runtime and reused. +Error handling in distributed systems is fundamentally different from local error handling. When an error occurs on the server, it must be serialized, transmitted over the network, and reconstructed on the client while preserving as much context as possible. Deepkit RPC provides sophisticated error handling that maintains error types, messages, stack traces, and custom properties across the network boundary. + +### How RPC Errors Work + +When an error occurs in a server action: + +1. **Error Capture**: The RPC kernel catches the thrown error +2. **Serialization**: Error properties (message, stack, custom fields) are serialized +3. **Type Preservation**: If the error class is registered, its type identity is preserved +4. **Network Transmission**: Serialized error data is sent to the client +5. **Reconstruction**: The client reconstructs the error with its original type and properties +6. **Client Handling**: The client can use `instanceof` checks and access all error properties + +### Error Serialization Process + +``` +Server Side Network Client Side +┌─────────────┐ ┌─────────┐ ┌─────────────┐ +│ throw new │ serialize │ BSON │ deserialize │ instanceof │ +│ CustomError │ ────────────→ │ Error │ ─────────────→ │ CustomError │ +│ │ │ Data │ │ === true │ +└─────────────┘ └─────────┘ └─────────────┘ +``` + +## Custom Error Classes + +For type-safe error handling, define custom error classes and register them with unique names: + +### Basic Custom Error ```typescript +import { entity } from '@deepkit/type'; + +// Register the error class with a unique name +// The '@error:' prefix is a convention for error types @entity.name('@error:myError') -class MyError extends Error {} +class MyError extends Error { + constructor(message: string) { + super(message); + this.name = 'MyError'; + } +} +``` + +### Rich Error Classes + +Create errors with additional context and properties: + +```typescript +@entity.name('@error:validationError') +class ValidationError extends Error { + constructor( + message: string, + public field: string, + public value: any, + public constraints: string[] + ) { + super(message); + this.name = 'ValidationError'; + } +} + +@entity.name('@error:notFoundError') +class NotFoundError extends Error { + constructor( + public resourceType: string, + public resourceId: string | number + ) { + super(`${resourceType} with ID ${resourceId} not found`); + this.name = 'NotFoundError'; + } +} + +@entity.name('@error:authenticationError') +class AuthenticationError extends Error { + constructor( + message: string, + public code: 'INVALID_CREDENTIALS' | 'TOKEN_EXPIRED' | 'ACCESS_DENIED' + ) { + super(message); + this.name = 'AuthenticationError'; + } +} +``` + +### Server-Side Error Throwing + +```typescript +@rpc.controller('/users') +class UserController { + constructor(private userService: UserService) {} + + @rpc.action() + async saveUser(user: CreateUserData): Promise { + // Validate input + if (!user.email || !user.email.includes('@')) { + throw new ValidationError( + 'Invalid email address', + 'email', + user.email, + ['must be a valid email address'] + ); + } + + // Check if user already exists + const existingUser = await this.userService.findByEmail(user.email); + if (existingUser) { + throw new ValidationError( + 'Email already in use', + 'email', + user.email, + ['must be unique'] + ); + } + + try { + return await this.userService.create(user); + } catch (dbError) { + // Wrap database errors in application errors + throw new Error(`Failed to save user: ${dbError.message}`); + } + } -//server -@rpc.controller('/main') -class Controller { @rpc.action() - saveUser(user: User): void { - throw new MyError('Can not save user'); + async getUser(id: number): Promise { + const user = await this.userService.findById(id); + if (!user) { + throw new NotFoundError('User', id); + } + return user; } } +``` + +### Client-Side Error Handling + +```typescript +// Register error classes with the client controller +const controller = client.controller('/users', [ + ValidationError, + NotFoundError, + AuthenticationError +]); + +async function createUser(userData: CreateUserData): Promise { + try { + const user = await controller.saveUser(userData); + console.log('User created successfully:', user); + return user; + + } catch (error) { + // Handle specific error types + if (error instanceof ValidationError) { + console.error(`Validation failed for ${error.field}:`, error.message); + console.error('Constraints:', error.constraints); + this.showFieldError(error.field, error.message); + + } else if (error instanceof NotFoundError) { + console.error(`Resource not found: ${error.resourceType} ${error.resourceId}`); + this.showNotFoundMessage(error.resourceType); + + } else if (error instanceof AuthenticationError) { + console.error(`Authentication failed: ${error.code}`); + if (error.code === 'TOKEN_EXPIRED') { + this.redirectToLogin(); + } + + } else { + // Handle unexpected errors + console.error('Unexpected error:', error.message); + this.showGenericErrorMessage(); + } -//client -//[MyError] makes sure the class MyError is known in runtime -const controller = client.controller('/main', [MyError]); - -try { - await controller.getUser(2); -} catch (e) { - if (e instanceof MyError) { - //ops, could not save user - } else { - //all other errors + return null; } } ``` diff --git a/website/src/pages/documentation/rpc/getting-started.md b/website/src/pages/documentation/rpc/getting-started.md index 0303a1b95..25c01b431 100644 --- a/website/src/pages/documentation/rpc/getting-started.md +++ b/website/src/pages/documentation/rpc/getting-started.md @@ -1,34 +1,106 @@ # Getting Started -To use Deepkit RPC, it is necessary to have `@deepkit/type` correctly installed because it is based on Runtime Types. See [Runtime Type Installation](../runtime-types.md). +## What is Deepkit RPC? -Once this is successfully done, `@deepkit/rpc` or the Deepkit Framework, which already uses the library under the hood, can be installed. +Deepkit RPC is a modern, type-safe Remote Procedure Call (RPC) framework that enables seamless communication between TypeScript applications. Unlike traditional REST APIs or GraphQL, Deepkit RPC allows you to call server functions directly from your client code as if they were local functions, while maintaining full TypeScript type safety across the network boundary. + +### Key Features + +- **Full Type Safety**: TypeScript types are preserved across client-server communication +- **Real-time Streaming**: Native support for RxJS Observables, Subjects, and BehaviorSubjects +- **Bidirectional Communication**: Both client and server can call methods on each other +- **Multiple Transport Protocols**: WebSockets, TCP, and HTTP support +- **Automatic Serialization**: Complex objects, classes, and binary data are handled automatically +- **Built-in Validation**: Automatic parameter and return value validation +- **Peer-to-Peer Communication**: Direct client-to-client communication through the server +- **Progress Tracking**: Built-in progress tracking for large data transfers + +### How It Works + +Deepkit RPC uses TypeScript's runtime type information (via `@deepkit/type`) to: + +1. **Serialize/Deserialize Data**: Convert complex TypeScript objects to binary format (BSON) for efficient network transmission +2. **Validate Parameters**: Automatically validate function parameters and return values +3. **Preserve Type Identity**: Maintain class instances and their methods across the network +4. **Generate Type-Safe Clients**: Create fully typed client interfaces that match server implementations + +The framework operates on a controller-action pattern where: +- **Controllers** are classes that group related functionality +- **Actions** are methods within controllers that can be called remotely +- **Clients** connect to servers and call actions through type-safe proxies + +## Installation + +### Prerequisites + +Deepkit RPC requires `@deepkit/type` for runtime type information. See [Runtime Type Installation](../runtime-types.md) for detailed setup instructions. + +### Core Package ```sh npm install @deepkit/rpc ``` -Note that controller classes in `@deepkit/rpc` are based on TypeScript decorators, and this feature must be enabled with experimentalDecorators. +**Important**: Enable TypeScript decorators in your `tsconfig.json`: + +```json +{ + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true + } +} +``` + +### Transport Packages -The `@deepkit/rpc` package must be installed on the server and client if they have their own package.json. +Choose the appropriate transport package based on your needs: -To communicate over TCP with the server, the `@deepkit/rpc-tcp` package must be installed on the client and server. +#### WebSocket Transport (Recommended) +```sh +npm install @deepkit/rpc-tcp +``` + +WebSockets provide the best balance of features and compatibility: +- ✅ Works in browsers and Node.js +- ✅ Supports all RPC features (streaming, bidirectional communication) +- ✅ Efficient binary protocol +- ✅ Real-time capabilities +#### TCP Transport (Server-to-Server) ```sh npm install @deepkit/rpc-tcp ``` -For WebSocket communication, the package is also required on the server. The client in the browser, on the other hand, uses WebSocket from the official standard. +TCP is ideal for microservice communication: +- ✅ Highest performance +- ✅ Supports all RPC features +- ❌ Not available in browsers +- ✅ Perfect for server-to-server communication -If the client is also to be used in an environment where WebSocket is not available (for example, NodeJS), the package ws is required in the client. +#### Node.js WebSocket Client +If using WebSocket clients in Node.js environments: ```sh npm install ws ``` -## Usage +## Quick Start Example + +Let's build a simple chat application to demonstrate Deepkit RPC's core concepts. This example shows the fundamental client-server communication pattern. + +### Understanding the Architecture + +Before diving into code, it's important to understand how Deepkit RPC works: -Below is a fully functional example based on WebSockets and the low-level API of @deepkit/rpc. When using the Deepkit Framework, controllers are provided via app modules, and an RpcKernel is not instantiated manually. +1. **Server Side**: Define controllers with actions that clients can call +2. **Client Side**: Create typed proxies that call server actions as if they were local functions +3. **Type Safety**: TypeScript types are preserved and validated across the network +4. **Serialization**: Complex objects are automatically converted to efficient binary format + +### Server Implementation + +The server defines what functionality is available to clients through controllers and actions. _File: server.ts_ @@ -36,25 +108,48 @@ _File: server.ts_ import { rpc, RpcKernel } from '@deepkit/rpc'; import { RpcWebSocketServer } from '@deepkit/rpc-tcp'; +// Define a controller - a group of related functionality @rpc.controller('/main') export class Controller { + // Define an action - a method that clients can call @rpc.action() hello(title: string): string { return 'Hello ' + title; } + + // Actions can be async and return complex types + @rpc.action() + async getUserInfo(userId: number): Promise<{ id: number, name: string, email: string }> { + // In a real app, this would query a database + return { + id: userId, + name: 'John Doe', + email: 'john@example.com' + }; + } } +// Create the RPC kernel - the core that manages controllers const kernel = new RpcKernel(); kernel.registerController(Controller); + +// Create a WebSocket server to handle client connections const server = new RpcWebSocketServer(kernel, 'localhost:8081'); server.start({ host: '127.0.0.1', port: 8081, }); -console.log('Server started at ws://127.0.0.1:8081'); +console.log('Server started at ws://127.0.0.1:8081'); +console.log('Available actions:'); +console.log('- /main.hello(title: string): string'); +console.log('- /main.getUserInfo(userId: number): Promise'); ``` +### Client Implementation + +The client connects to the server and calls actions through type-safe proxies. + _File: client.ts_ ```typescript @@ -62,29 +157,78 @@ import { RpcWebSocketClient } from '@deepkit/rpc'; import type { Controller } from './server'; async function main() { + // Create a WebSocket client connection const client = new RpcWebSocketClient('ws://127.0.0.1:8081'); + + // Get a typed controller proxy - this gives you full TypeScript intellisense const controller = client.controller('/main'); - const result = await controller.hello('World'); - console.log('result', result); + try { + // Call server actions as if they were local functions + // TypeScript knows the exact parameter and return types + const greeting = await controller.hello('World'); + console.log('Greeting:', greeting); // "Hello World" - client.disconnect(); + // Call async actions - the Promise is handled automatically + const userInfo = await controller.getUserInfo(123); + console.log('User info:', userInfo); // { id: 123, name: 'John Doe', email: 'john@example.com' } + + // TypeScript will catch type errors at compile time + // controller.hello(123); // ❌ Error: Argument of type 'number' is not assignable to parameter of type 'string' + + } catch (error) { + console.error('RPC call failed:', error); + } finally { + // Clean up the connection + client.disconnect(); + } } main().catch(console.error); - ``` -## Server Controller +### What's Happening Under the Hood + +When you call `controller.hello('World')`: + +1. **Type Checking**: TypeScript validates that 'World' is a valid string parameter +2. **Serialization**: The string parameter is serialized to BSON format +3. **Network Transport**: The serialized data is sent over the WebSocket connection +4. **Server Execution**: The server deserializes the parameter and calls the actual method +5. **Response Serialization**: The return value is serialized and sent back +6. **Client Deserialization**: The client receives and deserializes the response +7. **Type Safety**: The result is typed as `string` in your client code + +All of this happens transparently - you just call the function and get the result! + +## Understanding Controllers and Actions + +### What are Controllers? + +Controllers in Deepkit RPC are classes that group related functionality together. Think of them as service endpoints that expose specific capabilities to clients. Each controller has a unique path (like `/users` or `/auth`) that clients use to access its functionality. -The term "Procedure" in Remote Procedure Call is also commonly referred to as an "Action". An Action is a method defined in a class and marked with the `@rpc.action` decorator. The class itself is marked as a Controller with the `@rpc.controller` decorator and given a unique name. This name is then referenced in the client to address the correct controller. Multiple controllers can be defined and registered as needed. +### What are Actions? +Actions are the individual methods within a controller that clients can call remotely. The term "action" is used instead of "method" to emphasize that these are remote procedure calls - they execute on the server but are called from the client. + +### Controller Design Principles + +When designing controllers, follow these principles: + +1. **Single Responsibility**: Each controller should handle one domain or feature area +2. **Logical Grouping**: Group related actions together (e.g., user management, file operations) +3. **Clear Naming**: Use descriptive controller paths and action names +4. **Consistent Interface**: Maintain consistent parameter and return patterns + +### Basic Controller Structure ```typescript import { rpc } from '@deepkit/rpc'; -@rpc.controller('/main'); +// The controller decorator defines the path clients use to access this controller +@rpc.controller('/main') class Controller { + // Only methods marked with @rpc.action() are accessible to clients @rpc.action() hello(title: string): string { return 'Hello ' + title; @@ -94,54 +238,213 @@ class Controller { test(): boolean { return true; } + + // This method is NOT accessible to clients (no @rpc.action decorator) + private internalMethod(): void { + // Internal server logic + } +} +``` + +### Why Explicit Types Matter + +Deepkit RPC requires explicit type annotations because: + +1. **Runtime Type Information**: The serializer needs to know exact types to convert data to binary format (BSON) +2. **Validation**: Parameters and return values are validated against their declared types +3. **Client Generation**: Type information is used to create type-safe client proxies +4. **Cross-Language Support**: Explicit types enable potential future support for other languages + +```typescript +// ✅ Good - explicit types +@rpc.action() +getUserById(id: number): Promise { + return this.userService.findById(id); } + +// ❌ Bad - inferred types won't work properly +@rpc.action() +getUserById(id) { // Type inference doesn't provide runtime type info + return this.userService.findById(id); +} +``` + +### Controller Organization Patterns + +#### Feature-Based Controllers + +```typescript +@rpc.controller('/users') +class UserController { + @rpc.action() + create(userData: CreateUserData): Promise { /* ... */ } + + @rpc.action() + getById(id: number): Promise { /* ... */ } + + @rpc.action() + update(id: number, updates: Partial): Promise { /* ... */ } + + @rpc.action() + delete(id: number): Promise { /* ... */ } +} + +@rpc.controller('/auth') +class AuthController { + @rpc.action() + login(credentials: LoginCredentials): Promise { /* ... */ } + + @rpc.action() + logout(): Promise { /* ... */ } + + @rpc.action() + refreshToken(token: string): Promise { /* ... */ } +} +``` + +#### Hierarchical Controllers + +```typescript +@rpc.controller('/api/v1/users') +class UserV1Controller { /* ... */ } + +@rpc.controller('/api/v2/users') +class UserV2Controller { /* ... */ } + +@rpc.controller('/admin/users') +class AdminUserController { /* ... */ } ``` -Only methods marked as `@rpc.action()` can be called by a client. +## Bidirectional Communication -Types must be explicitly specified and cannot be inferred. This is important because the serializer needs to know exactly what the types look like in order to convert them into binary data (BSON) or JSON which is then sent over the wire. +One of Deepkit RPC's unique features is bidirectional communication - not only can clients call server methods, but servers can also call methods on connected clients. This enables powerful patterns like real-time notifications, user confirmations, and collaborative features. -## Client Controller +### Why Bidirectional Communication? -The normal flow in RPC is that the client can execute functions on the server. However, in Deepkit RPC, it is also possible for the server to execute functions on the client. To allow this, the client can also register a controller. +Traditional RPC systems are unidirectional: clients make requests to servers. Bidirectional RPC enables: + +- **Real-time Notifications**: Servers can push updates to specific clients +- **User Interactions**: Servers can ask clients for confirmations or input +- **Collaborative Features**: Multiple clients can interact through the server +- **Event-Driven Architecture**: Servers can notify clients of state changes +- **Progressive Operations**: Servers can update clients on long-running task progress + +### Client-Side Controllers + +Clients can register controllers that servers can call: ```typescript -// Client-side controller +// Define a client-side controller for receiving server calls @rpc.controller('/client') class ClientController { @rpc.action() - notify(message: string): void { - console.log('Server notification:', message); + notify(message: string, type: 'info' | 'warning' | 'error' = 'info'): void { + // Handle server notifications + console.log(`[${type.toUpperCase()}] ${message}`); + + // In a real application, you might show a toast notification + this.showNotification(message, type); } @rpc.action() confirm(question: string): boolean { + // Server is asking for user confirmation return confirm(question); } + + @rpc.action() + updateProgress(current: number, total: number, operation: string): void { + // Server is reporting progress on a long-running operation + const percentage = Math.round((current / total) * 100); + console.log(`${operation}: ${percentage}% complete`); + this.updateProgressBar(percentage); + } + + private showNotification(message: string, type: string): void { + // Implementation depends on your UI framework + } + + private updateProgressBar(percentage: number): void { + // Update UI progress indicator + } } // Register the controller on the client client.registerController(ClientController, '/client'); +``` + +### Server-Side Bidirectional Communication + +Servers can call client methods through the connection object: + +```typescript +import { RpcKernelConnection } from '@deepkit/rpc'; -// Server can now call client methods @rpc.controller('/server') class ServerController { + // The connection is injected automatically and represents the current client constructor(private connection: RpcKernelConnection) {} @rpc.action() - async processData(): Promise { - // Call client controller from server + async processLargeDataset(datasetId: string): Promise<{ success: boolean, recordsProcessed: number }> { + // Get a proxy to the client's controller const clientController = this.connection.controller('/client'); - await clientController.notify('Processing started...'); - // Ask for confirmation - const confirmed = await clientController.confirm('Continue processing?'); - return confirmed; + // Notify client that processing is starting + await clientController.notify('Starting dataset processing...', 'info'); + + // Ask for confirmation before proceeding + const confirmed = await clientController.confirm( + 'This operation will take several minutes. Continue?' + ); + + if (!confirmed) { + await clientController.notify('Operation cancelled by user', 'warning'); + return { success: false, recordsProcessed: 0 }; + } + + // Simulate processing with progress updates + const totalRecords = 10000; + let processedRecords = 0; + + for (let batch = 0; batch < 10; batch++) { + // Process a batch of records + await this.processBatch(datasetId, batch); + processedRecords += 1000; + + // Update client on progress + await clientController.updateProgress( + processedRecords, + totalRecords, + 'Processing dataset' + ); + + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // Notify completion + await clientController.notify('Dataset processing completed successfully!', 'info'); + + return { success: true, recordsProcessed: totalRecords }; + } + + private async processBatch(datasetId: string, batchNumber: number): Promise { + // Actual batch processing logic } } ``` -See [Advanced Features](advanced-features.md) for more information about bidirectional communication. +### Use Cases for Bidirectional Communication + +1. **Real-time Dashboards**: Server pushes live data updates to dashboard clients +2. **Collaborative Editing**: Multiple users editing the same document with real-time sync +3. **Admin Operations**: Admin interface that can send commands to specific client instances +4. **Gaming**: Real-time multiplayer game state synchronization +5. **Monitoring**: Server health monitoring with real-time alerts to admin clients +6. **Chat Applications**: Real-time message delivery between users + +See [Advanced Features](advanced-features.md) for more detailed information about bidirectional communication patterns. ## Dependency Injection diff --git a/website/src/pages/documentation/rpc/interfaces.md b/website/src/pages/documentation/rpc/interfaces.md index 0ea7b53f4..ef097fad1 100644 --- a/website/src/pages/documentation/rpc/interfaces.md +++ b/website/src/pages/documentation/rpc/interfaces.md @@ -1,6 +1,53 @@ # RPC Interfaces -RPC interfaces provide a type-safe way to define contracts between your backend and frontend. Using `ControllerSymbol` and TypeScript interfaces, you can ensure that both sides of your application stay in sync and benefit from full type safety. +## The Power of Type-Safe Contracts + +RPC interfaces represent one of Deepkit RPC's most powerful features: the ability to define type-safe contracts between your client and server that are enforced at compile time, runtime, and across the network boundary. This goes far beyond simple API documentation - it creates a living contract that prevents integration bugs and enables confident refactoring. + +### Why RPC Interfaces Matter + +Traditional API development suffers from several problems: + +1. **API Drift**: Client and server implementations diverge over time +2. **Runtime Errors**: Type mismatches are only discovered at runtime +3. **Documentation Lag**: API documentation becomes outdated +4. **Refactoring Fear**: Changing APIs is risky due to unknown dependencies +5. **Integration Bugs**: Client-server integration issues are common + +RPC interfaces solve these problems by: + +- **Compile-Time Safety**: TypeScript catches type mismatches before deployment +- **Living Documentation**: The interface IS the documentation and it's always current +- **Refactoring Confidence**: Changes to interfaces are caught immediately +- **Contract Enforcement**: Both client and server must implement the same interface +- **IDE Support**: Full IntelliSense and auto-completion across the network boundary + +### How RPC Interfaces Work + +``` +┌─────────────────┐ Shared Interface ┌─────────────────┐ +│ Client │ ←─────────────────────→ │ Server │ +│ │ │ │ +│ controller() │ ← Type-Safe Proxy ← │ implements T │ +│ │ │ │ +│ Compile-Time │ Runtime Validation │ Compile-Time │ +│ Type Checking │ ←─────────────────────→ │ Type Checking │ +└─────────────────┘ └─────────────────┘ +``` + +The interface serves as a contract that: +1. **Client** uses to generate type-safe method calls +2. **Server** implements to ensure API compliance +3. **TypeScript** uses to validate both sides at compile time +4. **Deepkit RPC** uses to validate data at runtime + +### Interface Architecture + +RPC interfaces consist of three main components: + +1. **ControllerSymbol**: A unique identifier that links client and server +2. **TypeScript Interface**: Defines the method signatures and types +3. **Entity Classes**: Define the data structures used in the interface ## Basic Interface Definition diff --git a/website/src/pages/documentation/rpc/peer-to-peer.md b/website/src/pages/documentation/rpc/peer-to-peer.md index 9a34de1ef..26b1063d4 100644 --- a/website/src/pages/documentation/rpc/peer-to-peer.md +++ b/website/src/pages/documentation/rpc/peer-to-peer.md @@ -1,6 +1,111 @@ # Peer-to-Peer Communication -Deepkit RPC supports peer-to-peer communication through the Deepkit Broker, allowing clients to communicate directly with each other through the server. This enables powerful distributed architectures and real-time collaboration features. +## Understanding RPC Peer-to-Peer Architecture + +Peer-to-peer (P2P) communication in Deepkit RPC represents a paradigm shift from traditional client-server models to distributed architectures where clients can communicate directly with each other through the server acting as a broker. This enables powerful patterns for real-time collaboration, distributed computing, and decentralized applications while maintaining the security and control of a centralized broker. + +### Traditional vs P2P Communication Models + +#### Traditional Client-Server Model +``` +Client A ──────→ Server ←────── Client B + Request Request + +Client A ←────── Server ──────→ Client B + Response Response +``` + +**Limitations:** +- All communication must go through server business logic +- Server becomes a bottleneck for client-to-client interactions +- Difficult to implement real-time collaboration features +- Server must maintain state for all client interactions + +#### P2P Broker Model +``` +Client A ←──────→ Server ←──────→ Client B + Register Register + +Client A ←─────────────────────→ Client B + Direct P2P Communication + (through server broker) +``` + +**Advantages:** +- Direct client-to-client communication +- Server acts as secure broker and coordinator +- Reduced server load for client interactions +- Natural fit for collaborative applications +- Scalable architecture for distributed systems + +### How P2P Works in Deepkit RPC + +Deepkit RPC's P2P system operates through a broker pattern: + +1. **Client Registration**: Clients register themselves as peers with unique IDs +2. **Capability Advertisement**: Peers can advertise their available controllers/services +3. **Discovery**: Peers can discover other available peers and their capabilities +4. **Secure Communication**: All P2P communication is mediated by the server for security +5. **Type Safety**: Full TypeScript type safety is maintained across peer connections + +### P2P Architecture Components + +``` +┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ +│ Peer A │ │ RPC Broker │ │ Peer B │ +│ │ │ (Server) │ │ │ +│ Controllers │◄──►│ - Peer Registry │◄──►│ Controllers │ +│ Services │ │ - Security │ │ Services │ +│ Capabilities│ │ - Routing │ │ Capabilities│ +└─────────────┘ │ - Discovery │ └─────────────┘ + └─────────────────┘ +``` + +### Key P2P Concepts + +#### Peer Identity +- **Unique Peer IDs**: Each peer has a unique identifier +- **Registration**: Peers must register with the broker before communication +- **Authentication**: Peer registration can be secured with authentication +- **Lifecycle Management**: Automatic cleanup when peers disconnect + +#### Peer Controllers +- **Distributed Services**: Peers can expose RPC controllers to other peers +- **Service Discovery**: Peers can discover available services on other peers +- **Type Safety**: Full TypeScript interfaces for peer-to-peer calls +- **Error Handling**: Proper error propagation across peer connections + +#### Security Model +- **Broker Mediation**: All communication goes through the secure broker +- **Access Control**: Fine-grained control over peer-to-peer permissions +- **Authentication**: Peer registration and communication can be authenticated +- **Audit Trail**: All peer interactions can be logged and monitored + +### Use Cases for P2P Communication + +#### Real-time Collaboration +- **Document Editing**: Multiple users editing the same document +- **Whiteboarding**: Shared drawing and annotation tools +- **Code Collaboration**: Real-time code editing and review +- **Design Tools**: Collaborative design and prototyping + +#### Gaming and Entertainment +- **Multiplayer Games**: Real-time game state synchronization +- **Chat Systems**: Direct messaging between players +- **Matchmaking**: Peer discovery and game room creation +- **Streaming**: Peer-to-peer content streaming + +#### Distributed Computing +- **Task Distribution**: Distributing work across multiple clients +- **Data Processing**: Collaborative data analysis and processing +- **Resource Sharing**: Sharing computational resources between peers +- **Load Balancing**: Distributing load across peer nodes + +#### Communication Platforms +- **Video Conferencing**: Direct peer-to-peer video/audio streams +- **File Sharing**: Direct file transfers between users +- **Screen Sharing**: Remote desktop and screen sharing +- **Instant Messaging**: Real-time messaging with presence ## Basic Peer-to-Peer Setup diff --git a/website/src/pages/documentation/rpc/progress-tracking.md b/website/src/pages/documentation/rpc/progress-tracking.md index f2063a675..44f21dfcd 100644 --- a/website/src/pages/documentation/rpc/progress-tracking.md +++ b/website/src/pages/documentation/rpc/progress-tracking.md @@ -1,6 +1,87 @@ # Progress Tracking -Deepkit RPC automatically provides progress tracking for large data transfers. When sending or receiving large payloads, the data is automatically chunked and progress information is made available to track upload and download progress. +## Understanding RPC Progress Tracking + +Progress tracking in RPC systems addresses a critical user experience challenge: providing feedback during long-running operations or large data transfers. Without progress information, users are left wondering if their operation is working, how long it will take, or if it has failed. Deepkit RPC's automatic progress tracking provides real-time feedback for any operation that involves significant data transfer. + +### Why Progress Tracking Matters + +Modern applications handle increasingly large amounts of data: + +- **File Uploads**: Documents, images, videos, datasets +- **Data Exports**: Reports, backups, bulk data downloads +- **Batch Operations**: Processing thousands of records +- **Streaming Data**: Real-time data feeds, live updates +- **Synchronization**: Syncing large datasets between systems + +Without progress tracking, users experience: +- **Uncertainty**: Is the operation working or stuck? +- **Frustration**: How much longer will this take? +- **Abandonment**: Users may cancel operations that are actually progressing +- **Poor UX**: No feedback creates anxiety and confusion + +### How Deepkit RPC Progress Tracking Works + +Deepkit RPC implements automatic progress tracking through several mechanisms: + +1. **Automatic Chunking**: Large messages are automatically split into chunks +2. **Progress Events**: Each chunk transfer generates progress events +3. **Bidirectional Tracking**: Both upload and download progress are tracked +4. **Type-Agnostic**: Works with any data type (objects, arrays, binary data) +5. **Memory Efficient**: Streaming prevents memory overflow +6. **Error Resilient**: Progress continues even if individual chunks fail + +### The Chunking System + +``` +Large Data (1MB) +┌─────────────────────────────────────────────────────┐ +│ Original Data │ +└─────────────────────────────────────────────────────┘ + │ + Automatic Chunking + │ + ▼ +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Chunk 1 │ │ Chunk 2 │ │ Chunk 3 │ │ Chunk 4 │ +│ (64KB) │ │ (64KB) │ │ (64KB) │ │ (64KB) │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + Progress Progress Progress Progress + Event Event Event Event +``` + +### Progress Information Structure + +Progress events provide comprehensive information: + +```typescript +interface ProgressInfo { + current: number; // Bytes transferred so far + total: number; // Total bytes to transfer + progress: number; // Completion ratio (0.0 to 1.0) + speed?: number; // Transfer speed in bytes/second + eta?: number; // Estimated time remaining in seconds + chunkIndex?: number; // Current chunk being processed + totalChunks?: number; // Total number of chunks +} +``` + +### Automatic vs Manual Progress + +Deepkit RPC provides two types of progress tracking: + +#### Automatic Progress (Recommended) +- **Zero Configuration**: Works automatically for large data transfers +- **Transparent**: No changes needed to existing RPC actions +- **Efficient**: Optimized chunk sizes and transfer patterns +- **Reliable**: Built-in error handling and retry logic + +#### Manual Progress (Advanced) +- **Custom Control**: Define your own progress reporting logic +- **Business Logic**: Report progress based on business operations, not just data transfer +- **Complex Operations**: Multi-stage operations with custom progress calculation ## Basic Usage diff --git a/website/src/pages/documentation/rpc/security.md b/website/src/pages/documentation/rpc/security.md index 01cf821fa..65f41b79d 100644 --- a/website/src/pages/documentation/rpc/security.md +++ b/website/src/pages/documentation/rpc/security.md @@ -1,34 +1,206 @@ # Security -By default, all RPC functions can be called from any client, and the peer-to-peer communication feature is enabled. To precisely control which client is allowed to do what, you can override the `RpcKernelSecurity` class. +## Understanding RPC Security + +Security in RPC systems is critical because you're exposing server functionality directly to clients over the network. Unlike traditional web applications where you control the HTTP endpoints, RPC systems allow clients to call server methods directly, making proper access control essential. + +### Security Challenges in RPC + +RPC systems face unique security challenges: + +1. **Direct Method Access**: Clients can call server methods directly, bypassing traditional web security layers +2. **Type Safety vs Security**: Type safety doesn't guarantee security - you need explicit access controls +3. **Bidirectional Communication**: Both client and server can initiate calls, expanding the attack surface +4. **Peer-to-Peer Communication**: Clients can potentially communicate with each other through the server +5. **State Management**: Long-lived connections maintain state that needs to be secured +6. **Data Exposure**: Rich type information might expose more data than intended + +### Deepkit RPC Security Model + +Deepkit RPC implements a comprehensive security model through the `RpcKernelSecurity` class: + +``` +┌─────────────┐ Authentication ┌─────────────┐ +│ Client │ ────────────────────→ │ Server │ +│ │ │ │ +│ Token/Auth │ ← Session Creation ← │ RpcKernel │ +│ │ │ Security │ +│ RPC Calls │ → Access Control → │ │ +└─────────────┘ └─────────────┘ +``` + +### Default Security Behavior + +**⚠️ Important**: By default, Deepkit RPC is permissive: +- All RPC actions can be called by any client +- Peer-to-peer communication is enabled +- No authentication is required +- All error details are forwarded to clients + +This makes development easy but is **not suitable for production**. You must implement proper security controls. + +## Implementing Security + +### The RpcKernelSecurity Class + +The `RpcKernelSecurity` class is your main tool for implementing security. It provides hooks for: + +- **Authentication**: Validating client credentials and creating sessions +- **Authorization**: Controlling access to specific controllers and actions +- **Peer-to-Peer Control**: Managing client-to-client communication +- **Error Transformation**: Controlling what error information is exposed ```typescript -import { RpcKernelSecurity, Session, RpcControllerAccess } from '@deepkit/type'; +import { RpcKernelSecurity, Session, RpcControllerAccess } from '@deepkit/rpc'; -//contains default implementations class MyKernelSecurity extends RpcKernelSecurity { + // Control access to specific RPC actions async hasControllerAccess(session: Session, controllerAccess: RpcControllerAccess): Promise { + // Default: allow all access (override this!) return true; } + // Control peer registration async isAllowedToRegisterAsPeer(session: Session, peerId: string): Promise { + // Default: allow all peer registration (override this!) return true; } + // Control peer-to-peer communication async isAllowedToSendToPeer(session: Session, peerId: string): Promise { + // Default: allow all peer communication (override this!) return true; } + // Authenticate clients and create sessions async authenticate(token: any): Promise { + // Default: throw error (you must implement this!) throw new Error('Authentication not implemented'); } - transformError(err: Error) { + // Transform errors before sending to clients + transformError(err: Error): Error { + // Default: return error as-is (consider security implications!) return err; } } ``` +### Security Implementation Patterns + +#### Role-Based Access Control (RBAC) + +```typescript +import { entity } from '@deepkit/type'; + +// Define user roles +enum UserRole { + GUEST = 'guest', + USER = 'user', + ADMIN = 'admin', + SUPER_ADMIN = 'super_admin' +} + +// Custom session with user information +@entity.name('authenticated-session') +class AuthenticatedSession extends Session { + constructor( + public userId: string, + public username: string, + public roles: UserRole[], + public permissions: string[], + token?: any + ) { + super(username, token); + } + + hasRole(role: UserRole): boolean { + return this.roles.includes(role); + } + + hasPermission(permission: string): boolean { + return this.permissions.includes(permission); + } + + isAdmin(): boolean { + return this.hasRole(UserRole.ADMIN) || this.hasRole(UserRole.SUPER_ADMIN); + } +} + +class RoleBasedSecurity extends RpcKernelSecurity { + constructor(private userService: UserService) { + super(); + } + + async authenticate(token: string): Promise { + if (!token) { + throw new Error('Authentication token required'); + } + + try { + // Verify JWT token or API key + const payload = await this.verifyToken(token); + + // Load user information + const user = await this.userService.findById(payload.userId); + if (!user) { + throw new Error('User not found'); + } + + // Create authenticated session + return new AuthenticatedSession( + user.id, + user.username, + user.roles, + user.permissions, + token + ); + } catch (error) { + throw new Error('Invalid authentication token'); + } + } + + async hasControllerAccess(session: Session, access: RpcControllerAccess): Promise { + // Allow unauthenticated access to public controllers + if (access.actionGroups.includes('public')) { + return true; + } + + // Require authentication for all other controllers + if (!(session instanceof AuthenticatedSession)) { + return false; + } + + // Check role-based access + if (access.actionGroups.includes('admin')) { + return session.isAdmin(); + } + + if (access.actionGroups.includes('user')) { + return session.hasRole(UserRole.USER) || session.isAdmin(); + } + + // Check permission-based access + const requiredPermission = access.actionData['permission']; + if (requiredPermission) { + return session.hasPermission(requiredPermission); + } + + // Default: allow authenticated users + return true; + } + + private async verifyToken(token: string): Promise<{ userId: string }> { + // Implement JWT verification or API key validation + // This is a simplified example + if (token.startsWith('user-')) { + return { userId: token.replace('user-', '') }; + } + throw new Error('Invalid token format'); + } +} +``` + To use this, pass the provider to the `RpcKernel`: ```typescript diff --git a/website/src/pages/documentation/rpc/streaming.md b/website/src/pages/documentation/rpc/streaming.md index 68d3d9154..a37d20869 100644 --- a/website/src/pages/documentation/rpc/streaming.md +++ b/website/src/pages/documentation/rpc/streaming.md @@ -1,10 +1,81 @@ # Streaming with RxJS -Deepkit RPC provides native support for RxJS streaming, allowing you to work with real-time data flows between client and server. You can return `Observable`, `Subject`, or `BehaviorSubject` from your RPC actions, and they will be automatically serialized and streamed to the client. +## Understanding RPC Streaming -## Observable +Deepkit RPC's streaming capabilities represent a fundamental shift from traditional request-response patterns to real-time, reactive data flows. By integrating natively with RxJS, Deepkit RPC enables you to build applications that respond to data changes in real-time, handle continuous data streams, and maintain live connections between clients and servers. -Use `Observable` for one-way data streams from server to client: +### Why Streaming Matters + +Traditional RPC systems are limited to simple request-response patterns: +- Client sends request → Server processes → Server sends response → Connection closes + +Streaming RPC enables continuous communication: +- Client subscribes → Server streams data continuously → Client receives real-time updates → Connection stays alive + +This enables powerful use cases: +- **Real-time Dashboards**: Live metrics and monitoring data +- **Chat Applications**: Instant message delivery +- **Collaborative Editing**: Real-time document synchronization +- **Live Data Feeds**: Stock prices, sensor data, social media feeds +- **Progress Tracking**: Long-running operation updates +- **Event Sourcing**: Streaming event logs and state changes + +### How Streaming Works in Deepkit RPC + +Deepkit RPC's streaming is built on several key technologies: + +1. **RxJS Integration**: Native support for Observables, Subjects, and BehaviorSubjects +2. **Efficient Serialization**: Streaming data is serialized to BSON for optimal performance +3. **Automatic Chunking**: Large data streams are automatically chunked for memory efficiency +4. **Type Safety**: Full TypeScript type safety for streaming data +5. **Error Propagation**: Errors in streams are properly forwarded to clients +6. **Backpressure Handling**: Built-in mechanisms to handle fast producers and slow consumers + +### RxJS Streaming Types + +Deepkit RPC supports three main RxJS types, each with different characteristics: + +| Type | Use Case | Behavior | Memory | +|------|----------|----------|---------| +| **Observable** | One-way data streams | Emits values, then completes | Low | +| **Subject** | Multi-cast streaming | Multiple subscribers, no initial value | Medium | +| **BehaviorSubject** | State streaming | Multiple subscribers, has current value | Higher | + +### Streaming Architecture + +``` +┌─────────────┐ WebSocket/TCP ┌─────────────┐ +│ Client │ ←──────────────────→ │ Server │ +│ │ │ │ +│ Observable │ ← Streaming Data ← │ Observable │ +│ Subject │ ↔ Bidirectional ↔ │ Subject │ +│ BehaviorSub │ ← Current State ← │ BehaviorSub │ +└─────────────┘ └─────────────┘ +``` + +The server creates RxJS streams and returns them from RPC actions. Deepkit RPC automatically: +1. Serializes each emitted value to BSON +2. Sends it over the network connection +3. Deserializes it on the client +4. Reconstructs the RxJS stream with proper typing + +## Observable Streams + +Observables are perfect for one-way data streams from server to client. They represent a sequence of values emitted over time, and they complete when the data source is exhausted or an error occurs. + +### When to Use Observables + +- **Finite Data Streams**: When you have a known set of data to stream (e.g., file contents, database results) +- **One-Time Operations**: Operations that produce multiple values but eventually complete +- **Historical Data**: Streaming past events or records +- **Batch Processing**: Streaming results of batch operations + +### Observable Characteristics + +- **Cold Streams**: Each subscription creates a new execution +- **Completion**: Observables signal when they're done emitting values +- **Memory Efficient**: No state is maintained between emissions +- **Cleanup**: Automatic cleanup when client unsubscribes ```typescript import { Observable } from 'rxjs'; diff --git a/website/src/pages/documentation/rpc/transport.md b/website/src/pages/documentation/rpc/transport.md index b08ec93d0..1556296d1 100644 --- a/website/src/pages/documentation/rpc/transport.md +++ b/website/src/pages/documentation/rpc/transport.md @@ -1,6 +1,72 @@ -# Transport Protocol +# Transport Protocols -Deepkit RPC supports multiple transport protocols to accommodate different use cases and environments. Each transport has its own advantages and trade-offs. +## Understanding RPC Transport + +The transport layer is the foundation of any RPC system - it determines how data flows between client and server, what features are available, and how the system performs under different conditions. Deepkit RPC's pluggable transport architecture allows you to choose the optimal protocol for your specific use case while maintaining the same high-level API. + +### Transport Layer Responsibilities + +The transport layer handles: + +1. **Connection Management**: Establishing, maintaining, and closing connections +2. **Data Serialization**: Converting RPC messages to wire format +3. **Message Framing**: Delimiting message boundaries in the data stream +4. **Error Handling**: Managing network errors and connection failures +5. **Flow Control**: Managing data flow to prevent buffer overflow +6. **Security**: Encryption, authentication, and authorization at the transport level + +### Choosing the Right Transport + +Different transports excel in different scenarios: + +| Use Case | Recommended Transport | Why | +|----------|----------------------|-----| +| **Web Applications** | WebSockets | Browser compatibility, full feature support | +| **Microservices** | TCP | Maximum performance, server-to-server | +| **Mobile Apps** | WebSockets | Works through firewalls, handles network changes | +| **IoT Devices** | TCP | Lower overhead, efficient for constrained devices | +| **Development/Testing** | Direct | No network overhead, perfect for unit tests | +| **Legacy Integration** | HTTP | Compatible with existing HTTP infrastructure | + +### Transport Feature Matrix + +Understanding what each transport supports helps you make informed decisions: + +| Feature | WebSockets | TCP | HTTP | Direct | +|---------|------------|-----|------|--------| +| **Browser Support** | ✅ Native | ❌ No | ✅ Yes | ❌ No | +| **Streaming (RxJS)** | ✅ Full | ✅ Full | ❌ No | ✅ Full | +| **Bidirectional** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **Performance** | Good | Excellent | Fair | Excellent | +| **Connection Overhead** | Medium | Low | High | None | +| **Firewall Friendly** | ✅ Yes | ❌ Often blocked | ✅ Yes | N/A | +| **Load Balancer Support** | Good | Fair | Excellent | N/A | +| **Real-time Capabilities** | ✅ Excellent | ✅ Excellent | ❌ No | ✅ Perfect | +| **Message Ordering** | ✅ Guaranteed | ✅ Guaranteed | ❌ No | ✅ Guaranteed | +| **Automatic Reconnection** | ✅ Available | ✅ Available | N/A | N/A | + +### Protocol Deep Dive + +#### WebSocket Protocol +- **Underlying**: TCP with HTTP upgrade handshake +- **Message Format**: Binary frames with BSON payload +- **Connection**: Persistent, full-duplex +- **Overhead**: ~2-14 bytes per message frame +- **Best For**: Web applications, real-time features + +#### TCP Protocol +- **Underlying**: Raw TCP sockets +- **Message Format**: Length-prefixed BSON messages +- **Connection**: Persistent, full-duplex +- **Overhead**: ~4 bytes per message (length prefix) +- **Best For**: Server-to-server communication, maximum performance + +#### HTTP Protocol +- **Underlying**: HTTP/1.1 or HTTP/2 +- **Message Format**: JSON over HTTP POST +- **Connection**: Request-response only +- **Overhead**: ~200-500 bytes per request (HTTP headers) +- **Best For**: Simple integrations, debugging, legacy systems ## WebSockets From 4c5ab824672bdbb3bb54fdf2e0087c2f1b0ba98e Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 22:01:02 +0200 Subject: [PATCH 13/15] docs: improve reconnecting capabilities for rpc --- .../src/pages/documentation/rpc/transport.md | 313 +++++++++++++++++- 1 file changed, 295 insertions(+), 18 deletions(-) diff --git a/website/src/pages/documentation/rpc/transport.md b/website/src/pages/documentation/rpc/transport.md index 1556296d1..30c111d41 100644 --- a/website/src/pages/documentation/rpc/transport.md +++ b/website/src/pages/documentation/rpc/transport.md @@ -43,7 +43,7 @@ Understanding what each transport supports helps you make informed decisions: | **Load Balancer Support** | Good | Fair | Excellent | N/A | | **Real-time Capabilities** | ✅ Excellent | ✅ Excellent | ❌ No | ✅ Perfect | | **Message Ordering** | ✅ Guaranteed | ✅ Guaranteed | ❌ No | ✅ Guaranteed | -| **Automatic Reconnection** | ✅ Available | ✅ Available | N/A | N/A | +| **Reconnection on Action** | ✅ Yes | ✅ Yes | N/A | N/A | ### Protocol Deep Dive @@ -269,46 +269,323 @@ const result = await controller.myAction('test'); | Real-time | ✅ | ✅ | ❌ | ✅ | | Connection Pooling | ✅ | ✅ | ✅ | N/A | -## Connection Management +## Connection Management and Reconnection + +Understanding how Deepkit RPC handles connections and reconnections is crucial for building robust applications. The RPC client provides fine-grained control over connection behavior. + +### Important Reconnection Behavior + +**Key Point**: The RPC client does **NOT** automatically reconnect on its own. Reconnection only happens when you trigger an action after a disconnect. + +```typescript +const client = new RpcWebSocketClient('ws://127.0.0.1:8081'); +await client.connect(); + +// If the connection drops here and you don't call any actions, +// the client will stay disconnected indefinitely +``` + +### How Reconnection Actually Works + +Reconnection is triggered **only** when you attempt to call an action while disconnected: + +```typescript +const client = new RpcWebSocketClient('ws://127.0.0.1:8081'); +const controller = client.controller('/api'); + +// Initial connection +await client.connect(); + +// Connection drops (network issue, server restart, etc.) +// Client is now disconnected but doesn't try to reconnect + +// This action call will trigger a reconnection attempt +try { + const result = await controller.someAction(); // Reconnects automatically + console.log('Action succeeded, connection restored'); +} catch (error) { + console.log('Action failed, connection could not be restored'); +} +``` ### Connection Events +Monitor connection state changes with these events: + ```typescript const client = new RpcWebSocketClient('ws://127.0.0.1:8081'); -client.onConnect.subscribe(() => { - console.log('Connected to server'); +// Connection established (including initial connection) +client.transporter.connection.subscribe((connected: boolean) => { + console.log('Connection state:', connected ? 'Connected' : 'Disconnected'); }); -client.onDisconnect.subscribe(() => { - console.log('Disconnected from server'); +// Reconnection events (not called for initial connection) +client.transporter.reconnected.subscribe((connectionId: number) => { + console.log('Reconnected with connection ID:', connectionId); + // Good place to refresh data that might have changed }); -client.onError.subscribe((error) => { +// Disconnection events +client.transporter.disconnected.subscribe((connectionId: number) => { + console.log('Disconnected, connection ID was:', connectionId); + // Connection will not be restored until next action call +}); + +// Error events (followed by disconnection) +client.transporter.errored.subscribe(({ connectionId, error }) => { console.error('Connection error:', error); }); await client.connect(); ``` -### Automatic Reconnection +### Manual Reconnection -```typescript -const client = new RpcWebSocketClient('ws://127.0.0.1:8081', { - // Enable automatic reconnection - autoReconnect: true, +You can manually trigger reconnection attempts: - // Reconnection delay (ms) - reconnectDelay: 1000, +```typescript +const client = new RpcWebSocketClient('ws://127.0.0.1:8081'); - // Maximum reconnection attempts - maxReconnectAttempts: 10, +async function tryReconnect() { + try { + await client.connect(); + console.log('Reconnected successfully'); + } catch (error) { + console.log('Reconnection failed:', error.message); + // Try again after delay + setTimeout(tryReconnect, 5000); + } +} - // Exponential backoff - reconnectBackoff: 1.5, +// Listen for disconnections and attempt manual reconnection +client.transporter.disconnected.subscribe(() => { + console.log('Connection lost, attempting to reconnect...'); + tryReconnect(); }); ``` +### Practical Reconnection Patterns + +#### Pattern 1: Reactive Data Refresh + +Refresh application data when reconnection occurs: + +```typescript +class DataService { + private client = new RpcWebSocketClient('ws://127.0.0.1:8081'); + private controller = this.client.controller('/data'); + + constructor() { + // Refresh data when reconnected + this.client.transporter.reconnected.subscribe(() => { + this.refreshAllData(); + }); + } + + private async refreshAllData() { + try { + // Reload critical data that might have changed during disconnect + await this.loadUserData(); + await this.loadNotifications(); + console.log('Data refreshed after reconnection'); + } catch (error) { + console.error('Failed to refresh data:', error); + } + } + + async loadUserData() { + return this.controller.getUserData(); + } + + async loadNotifications() { + return this.controller.getNotifications(); + } +} +``` + +#### Pattern 2: Connection Status UI + +Show connection status to users: + +```typescript +class ConnectionStatusService { + private client = new RpcWebSocketClient('ws://127.0.0.1:8081'); + public connectionStatus$ = new BehaviorSubject<'connected' | 'disconnected' | 'reconnecting'>('disconnected'); + + constructor() { + this.client.transporter.connection.subscribe(connected => { + this.connectionStatus$.next(connected ? 'connected' : 'disconnected'); + }); + + this.client.transporter.disconnected.subscribe(() => { + this.connectionStatus$.next('reconnecting'); + this.attemptReconnection(); + }); + } + + private async attemptReconnection() { + let attempts = 0; + const maxAttempts = 5; + + while (attempts < maxAttempts && this.connectionStatus$.value === 'reconnecting') { + try { + await this.client.connect(); + break; // Success, connection.subscribe will update status + } catch (error) { + attempts++; + console.log(`Reconnection attempt ${attempts}/${maxAttempts} failed`); + + if (attempts < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 2000 * attempts)); // Exponential backoff + } + } + } + + if (attempts >= maxAttempts) { + console.error('Failed to reconnect after maximum attempts'); + } + } +} +``` + +#### Pattern 3: Graceful Action Handling + +Handle actions gracefully during connection issues: + +```typescript +class RobustApiService { + private client = new RpcWebSocketClient('ws://127.0.0.1:8081'); + private controller = this.client.controller('/api'); + + async performAction(actionFn: () => Promise, retries = 3): Promise { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await actionFn(); + } catch (error) { + console.log(`Action attempt ${attempt}/${retries} failed:`, error.message); + + if (attempt === retries) { + throw new Error(`Action failed after ${retries} attempts: ${error.message}`); + } + + // Wait before retry + await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); + } + } + + throw new Error('Unexpected error in performAction'); + } + + async saveData(data: any) { + return this.performAction(() => this.controller.saveData(data)); + } + + async loadData(id: string) { + return this.performAction(() => this.controller.loadData(id)); + } +} +``` + +### Connection State Checking + +Check connection status before performing actions: + +```typescript +const client = new RpcWebSocketClient('ws://127.0.0.1:8081'); + +// Check if currently connected +if (client.transporter.isConnected()) { + console.log('Client is connected'); +} else { + console.log('Client is disconnected'); +} + +// Get current connection ID (increments on each reconnection) +const connectionId = client.transporter.connectionId; +console.log('Current connection ID:', connectionId); +``` + +### Best Practices for Reconnection + +1. **Don't rely on automatic reconnection** - implement your own reconnection logic +2. **Refresh data after reconnection** - server state may have changed during disconnect +3. **Show connection status to users** - let them know when the app is offline +4. **Implement retry logic for critical actions** - network issues are temporary +5. **Use exponential backoff** - avoid overwhelming the server with reconnection attempts + +```typescript +class BestPracticeClient { + private client = new RpcWebSocketClient('ws://127.0.0.1:8081'); + private reconnectAttempts = 0; + private maxReconnectAttempts = 10; + private baseReconnectDelay = 1000; + + constructor() { + this.setupConnectionHandling(); + } + + private setupConnectionHandling() { + // Reset reconnect attempts on successful connection + this.client.transporter.connection.subscribe(connected => { + if (connected) { + this.reconnectAttempts = 0; + } + }); + + // Handle disconnections with exponential backoff + this.client.transporter.disconnected.subscribe(() => { + this.scheduleReconnection(); + }); + + // Refresh data on reconnection + this.client.transporter.reconnected.subscribe(() => { + this.onReconnected(); + }); + } + + private scheduleReconnection() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('Maximum reconnection attempts reached'); + return; + } + + this.reconnectAttempts++; + const delay = this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts - 1); + + console.log(`Scheduling reconnection attempt ${this.reconnectAttempts} in ${delay}ms`); + + setTimeout(async () => { + try { + await this.client.connect(); + } catch (error) { + console.log('Reconnection failed:', error.message); + this.scheduleReconnection(); // Try again + } + }, delay); + } + + private async onReconnected() { + console.log('Reconnected successfully, refreshing application state'); + // Refresh critical application data + await this.refreshApplicationData(); + } + + private async refreshApplicationData() { + // Implement your data refresh logic here + } +} +``` + +### Connection Events Summary + +| Event | When Triggered | Use Case | +|-------|---------------|----------| +| `connection` | Connection state changes | Update UI connection status | +| `reconnected` | After successful reconnection | Refresh data, notify user | +| `disconnected` | When connection is lost | Start reconnection attempts | +| `errored` | Before disconnection on error | Log errors, show error messages | + ### Security #### TLS/SSL Support From e9acaf73716aeb2b10a1b3896d8d6f0b82d82dea Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sat, 2 Aug 2025 22:13:16 +0200 Subject: [PATCH 14/15] docs: improve http --- .../http/dependency-injection.md | 313 +++++++++- .../src/pages/documentation/http/events.md | 141 ++++- .../documentation/http/getting-started.md | 586 ++++++++++++++++-- .../pages/documentation/http/input-output.md | 307 +++++++-- .../pages/documentation/http/middleware.md | 213 ++++++- .../src/pages/documentation/http/security.md | 126 +++- website/src/pages/documentation/http/views.md | 260 +++++++- 7 files changed, 1716 insertions(+), 230 deletions(-) diff --git a/website/src/pages/documentation/http/dependency-injection.md b/website/src/pages/documentation/http/dependency-injection.md index 2aa0f9075..d4afc06a6 100644 --- a/website/src/pages/documentation/http/dependency-injection.md +++ b/website/src/pages/documentation/http/dependency-injection.md @@ -1,70 +1,329 @@ -# Dependency Injection +# Dependency Injection in HTTP -The router functions as well as the controller classes and controller methods can define arbitrary dependencies, which are resolved by the dependency injection container. For example, it is possible to conveniently get to a database abstraction or logger. +Dependency Injection (DI) is a fundamental design pattern that Deepkit HTTP leverages to create clean, testable, and maintainable applications. Instead of creating dependencies manually, you declare what you need and let the DI container provide it. -For example, if a database has been provided as a provider, it can be injected: +## Why Dependency Injection Matters + +### Traditional Approach (Without DI) +```typescript +class UserController { + @http.GET('/users/:id') + async getUser(id: number) { + // Tightly coupled - hard to test and maintain + const database = new Database('connection-string'); + const logger = new Logger('user-service'); + const cache = new RedisCache('redis://localhost'); + + logger.info(`Fetching user ${id}`); + const user = await database.query(User).filter({id}).findOne(); + await cache.set(`user:${id}`, user); + + return user; + } +} +``` + +### Dependency Injection Approach +```typescript +class UserController { + constructor( + private database: Database, + private logger: Logger, + private cache: CacheService + ) {} + + @http.GET('/users/:id') + async getUser(id: number) { + // Clean, testable, and flexible + this.logger.info(`Fetching user ${id}`); + const user = await this.database.query(User).filter({id}).findOne(); + await this.cache.set(`user:${id}`, user); + + return user; + } +} +``` + +### Benefits of DI in HTTP Applications + +- **Testability**: Easy to mock dependencies for unit testing +- **Flexibility**: Swap implementations without changing code +- **Separation of Concerns**: Controllers focus on HTTP logic, not object creation +- **Configuration**: Centralized dependency configuration +- **Lifecycle Management**: Automatic cleanup and resource management + +## How DI Works in Deepkit HTTP + +Deepkit HTTP integrates seamlessly with the DI container, allowing you to inject dependencies into: + +- **Controller constructors**: Shared dependencies across all methods +- **Route methods**: Method-specific dependencies +- **Functional routes**: Direct parameter injection +- **Event listeners**: Dependencies in workflow event handlers + +### Basic Dependency Injection + +First, register your services as providers: ```typescript class Database { - //... + constructor(private connectionString: string) {} + + async query(entity: any) { + // Database implementation + return { filter: () => ({ findOne: () => Promise.resolve({}) }) }; + } +} + +class Logger { + info(message: string) { + console.log(`[INFO] ${message}`); + } } const app = new App({ providers: [ Database, + Logger, + // Or with configuration + { provide: Database, useFactory: () => new Database('postgresql://...') } ], + controllers: [UserController] }); ``` -_Functional API:_ +### Injection in Controllers +#### Constructor Injection (Recommended) ```typescript -router.get('/user/:id', async (id: number, database: Database) => { +class UserController { + constructor( + private database: Database, + private logger: Logger + ) {} + + @http.GET('/users/:id') + async getUser(id: number) { + this.logger.info(`Fetching user ${id}`); + return await this.database.query(User).filter({id}).findOne(); + } + + @http.POST('/users') + async createUser(userData: CreateUserRequest) { + this.logger.info('Creating new user'); + return await this.database.save(userData); + } +} +``` + +#### Method-Level Injection +```typescript +class UserController { + @http.GET('/users/:id') + async getUser(id: number, database: Database, logger: Logger) { + logger.info(`Fetching user ${id}`); + return await database.query(User).filter({id}).findOne(); + } +} +``` + +### Injection in Functional Routes + +```typescript +// Direct parameter injection +router.get('/users/:id', async (id: number, database: Database, logger: Logger) => { + logger.info(`Fetching user ${id}`); return await database.query(User).filter({id}).findOne(); }); + +// Mixed with HTTP parameters +router.post('/users', async ( + userData: HttpBody, + database: Database, + logger: Logger +) => { + logger.info('Creating new user'); + return await database.save(userData); +}); +``` + +See [Dependency Injection](../app/dependency-injection) for comprehensive DI documentation. + +## HTTP Scope and Request Lifecycle + +Understanding DI scopes is crucial for building efficient and secure HTTP applications. Deepkit HTTP uses a special `http` scope that creates a new DI container for each request, providing isolation and automatic cleanup. + +### How HTTP Scope Works + +``` +Request 1 → HTTP DI Container 1 → Response 1 → Container Destroyed +Request 2 → HTTP DI Container 2 → Response 2 → Container Destroyed +Request 3 → HTTP DI Container 3 → Response 3 → Container Destroyed +``` + +Each HTTP request gets its own isolated DI container that: +- **Inherits** singleton providers from the application scope +- **Creates** new instances of HTTP-scoped providers +- **Provides** request-specific objects like `HttpRequest` and `HttpResponse` +- **Cleans up** automatically when the request completes + +### Built-in HTTP Dependencies + +Deepkit automatically provides several objects in the HTTP scope: + +```typescript +import { HttpRequest, HttpResponse } from '@deepkit/http'; + +// Access request information +router.get('/info', (request: HttpRequest) => { + return { + method: request.method, + url: request.url, + headers: request.headers, + ip: request.ip + }; +}); + +// Direct response manipulation +router.get('/custom', (response: HttpResponse) => { + response.setHeader('X-Custom-Header', 'value'); + response.statusCode = 201; + response.end('Custom response'); +}); + +// Combined with other dependencies +router.get('/users/:id', ( + id: number, + request: HttpRequest, + database: Database, + logger: Logger +) => { + logger.info(`User ${id} requested from ${request.ip}`); + return database.findUser(id); +}); ``` -_Controller API:_ +### Creating HTTP-Scoped Services + +HTTP-scoped services are created fresh for each request, making them perfect for: +- **Request-specific state**: User sessions, request context +- **Per-request caching**: Avoid duplicate database calls within a request +- **Request tracking**: Logging, metrics, tracing +- **Security context**: Authentication state, permissions ```typescript -class UserController { - constructor(private database: Database) {} +class RequestContext { + private startTime = Date.now(); + private requestId = Math.random().toString(36); - @http.GET('/user/:id') - async userDetail(id: number) { - return await this.database.query(User).filter({id}).findOne(); + getRequestId(): string { + return this.requestId; + } + + getDuration(): number { + return Date.now() - this.startTime; } } -//alternatively directly in the method -class UserController { - @http.GET('/user/:id') - async userDetail(id: number, database: Database) { - return await database.query(User).filter({id}).findOne(); +class UserSession { + private user?: User; + + setUser(user: User): void { + this.user = user; + } + + getUser(): User { + if (!this.user) { + throw new HttpUnauthorizedError('Not authenticated'); + } + return this.user; + } + + isAuthenticated(): boolean { + return !!this.user; } } + +const app = new App({ + providers: [ + // Singleton services (shared across requests) + Database, + Logger, + + // HTTP-scoped services (new instance per request) + { provide: RequestContext, scope: 'http' }, + { provide: UserSession, scope: 'http' } + ] +}); ``` -See [Dependency Injection](dependency-injection) to learn more. +### Using HTTP-Scoped Services -## Scope +```typescript +class UserController { + @http.GET('/profile') + getProfile(session: UserSession, context: RequestContext) { + const user = session.getUser(); // Throws if not authenticated -All HTTP controllers and functional routes are managed within the `http` dependency injection scope. HTTP controllers are instantiated accordingly for each HTTP request. This also means that both can access providers registered for the `http` scope. So additionally `HttpRequest` and `HttpResponse` from `@deepkit/http` are usable as dependencies. If deepkit framework is used, `SessionHandler` from `@deepkit/framework` is also available. + return { + user: { id: user.id, username: user.username }, + requestId: context.getRequestId(), + processingTime: context.getDuration() + }; + } + + @http.POST('/login') + login( + credentials: LoginRequest, + session: UserSession, + authService: AuthService + ) { + const user = authService.authenticate(credentials); + session.setUser(user); // Set for this request + + return { success: true, user: { id: user.id, username: user.username } }; + } +} +``` + +### Memory Management and Performance + +HTTP scope provides automatic memory management: ```typescript -import { HttpResponse } from '@deepkit/http'; +class ExpensiveService { + private cache = new Map(); -router.get('/user/:id', (id: number, request: HttpRequest) => { -}); + constructor(private database: Database) { + console.log('ExpensiveService created for request'); + } + + async getData(key: string) { + if (this.cache.has(key)) { + return this.cache.get(key); + } + + const data = await this.database.query(key); + this.cache.set(key, data); // Cache only for this request + return data; + } +} -router.get('/', (response: HttpResponse) => { - response.end('Hello'); +// Register as HTTP-scoped +const app = new App({ + providers: [ + { provide: ExpensiveService, scope: 'http' } + ] }); ``` -It can be useful to place providers in the `http` scope, for example to instantiate services for each HTTP request. Once the HTTP request has been processed, the `http` scoped DI container is deleted, thus cleaning up all its provider instances from the garbage collector (GC). +**Benefits:** +- **Isolation**: Each request gets its own cache +- **Automatic cleanup**: Cache is destroyed when request completes +- **No memory leaks**: No need to manually clear caches +- **Performance**: Avoid duplicate work within a single request -See [Dependency Injection Scopes](dependency-injection.md#di-scopes) to learn how to place providers in the `http` scope. +See [Dependency Injection Scopes](../app/dependency-injection#di-scopes) for detailed scope documentation. ## HTTP-Specific Providers diff --git a/website/src/pages/documentation/http/events.md b/website/src/pages/documentation/http/events.md index aa824a37d..864158265 100644 --- a/website/src/pages/documentation/http/events.md +++ b/website/src/pages/documentation/http/events.md @@ -1,29 +1,134 @@ -# Events +# HTTP Events and Workflow -The HTTP module is based on a workflow engine that provides various event tokens that can be used to hook into the entire process of processing an HTTP request. +Deepkit HTTP is built around a sophisticated workflow engine that processes HTTP requests through a series of well-defined stages. This event-driven architecture allows you to hook into any part of the request/response cycle, providing unprecedented control and flexibility. -The workflow engine is a finite state machine that creates a new state machine instance for each HTTP request and then jumps from position to position. The first position is the `start` and the last the `response`. Additional code can be executed in each position. +## Understanding the HTTP Workflow + +The HTTP workflow is a finite state machine that processes each request through a predictable sequence of stages. Think of it as an assembly line where each station (event) can inspect, modify, or redirect the request. + +### Why Use an Event-Driven Architecture? + +- **Separation of Concerns**: Keep different aspects of request processing isolated +- **Extensibility**: Add new functionality without modifying core code +- **Testability**: Test individual workflow stages in isolation +- **Flexibility**: Conditionally execute logic based on request characteristics +- **Maintainability**: Clear, predictable flow makes debugging easier + +### The Request Lifecycle + +Every HTTP request flows through these stages: + +1. **Request Received**: Raw HTTP request arrives +2. **Route Resolution**: Find matching route for the request +3. **Authentication**: Verify user identity +4. **Parameter Resolution**: Extract and validate route parameters +5. **Authorization**: Check if user can access the resource +6. **Controller Execution**: Run the actual business logic +7. **Response Generation**: Convert result to HTTP response + +Each stage fires specific events that you can listen to and customize. ![HTTP Workflow](/assets/documentation/framework/http-workflow.png) -Each event token has its own event type with additional information. +### Event-Driven vs Traditional Middleware + +Traditional middleware runs in a linear chain, but Deepkit's event system allows for: + +- **Non-linear flow**: Jump between different workflow stages +- **Conditional execution**: Skip stages based on request characteristics +- **Rich context**: Access to full request/response state at each stage +- **Type safety**: Strongly typed event data and parameters + +## HTTP Workflow Events + +Each stage in the HTTP workflow fires specific events that you can listen to. Understanding when each event fires and what data is available helps you choose the right place to implement your logic. -| Event-Token | Description | -|-------------------------------|---------------------------------------------------------------------------------------------------------------------| -| httpWorkflow.onRequest | When a new request comes in | -| httpWorkflow.onRoute | When the route should be resolved from the request | -| httpWorkflow.onRouteNotFound | When the route is not found | -| httpWorkflow.onAuth | When authentication happens | -| httpWorkflow.onResolveParameters | When route parameters are resolved | -| httpWorkflow.onAccessDenied | When access is denied | -| httpWorkflow.onController | When the controller action is called | -| httpWorkflow.onControllerError | When the controller action threw an error | -| httpWorkflow.onParametersFailed | When route parameters resolving failed | -| httpWorkflow.onResponse | When the controller action has been called. This is the place where the result is converted to a response. | +| Event | When It Fires | Purpose | Available Data | +|-------|---------------|---------|----------------| +| `httpWorkflow.onRequest` | Request received | Initialize request processing | `HttpRequest`, `HttpResponse` | +| `httpWorkflow.onRoute` | Route resolution | Custom routing logic | Request, potential routes | +| `httpWorkflow.onRouteNotFound` | No route matches | Handle 404s, fallback routes | Request, attempted path | +| `httpWorkflow.onAuth` | Before authentication | Verify user identity | Request, route info | +| `httpWorkflow.onResolveParameters` | Parameter extraction | Custom parameter resolution | Route parameters, request | +| `httpWorkflow.onAccessDenied` | Authorization failed | Handle access denied | User context, route | +| `httpWorkflow.onController` | Before route handler | Pre-execution logic | Controller, method, parameters | +| `httpWorkflow.onControllerError` | Route handler error | Error handling | Error object, request context | +| `httpWorkflow.onParametersFailed` | Parameter validation failed | Handle validation errors | Validation errors, parameters | +| `httpWorkflow.onResponse` | After route handler | Response transformation | Route result, response object | + +### Event Execution Order and Priority + +Events execute in a specific order, and you can control when your listeners run relative to Deepkit's built-in handlers: + +```typescript +// Default priority (0) - runs BEFORE Deepkit's handlers +app.listen(httpWorkflow.onAuth, (event) => { + console.log('Custom auth logic runs first'); +}); + +// High priority (100+) - runs AFTER Deepkit's handlers +app.listen(httpWorkflow.onAuth, (event) => { + console.log('This runs after Deepkit processes auth'); +}, 150); + +// Low priority (negative) - runs last +app.listen(httpWorkflow.onResponse, (event) => { + console.log('Final response processing'); +}, -100); +``` -Since all HTTP events are based on the workflow engine, its behavior can be modified by using the specified event and jumping there with the `event.next()` method. +**Key Insight**: Deepkit's built-in HTTP handlers use priority 100. Your listeners run first by default (priority 0), giving you the opportunity to modify behavior before Deepkit's processing. -The HTTP module uses its own event listeners on these event tokens to implement HTTP request processing. All these event listeners have a priority of 100, which means that when you listen for an event, your listener is executed first by default (since the default priority is 0). Add a priority above 100 to run after the HTTP default handler. +### Controlling Workflow Flow + +The workflow engine allows you to control the flow of request processing: + +```typescript +app.listen(httpWorkflow.onAuth, (event) => { + if (!isAuthenticated(event.request)) { + // Jump directly to access denied, skipping normal flow + event.accessDenied(); + return; // Important: return to prevent further processing + } + + // Continue normal flow (implicit) +}); + +app.listen(httpWorkflow.onController, (event) => { + if (maintenanceMode) { + // Send response immediately, bypassing controller + event.send(new JSONResponse({ message: 'Under maintenance' }, 503)); + return; + } + + // Continue to controller execution +}); +``` + +### Event Data and Context + +Each event provides rich context about the current request state: + +```typescript +app.listen(httpWorkflow.onController, (event) => { + // Request information + console.log('Method:', event.request.method); + console.log('URL:', event.request.url); + console.log('Headers:', event.request.headers); + + // Route information + console.log('Route path:', event.route.path); + console.log('Route groups:', event.route.groups); + console.log('Route name:', event.route.name); + + // Controller information + console.log('Controller class:', event.controllerClass.name); + console.log('Method name:', event.methodName); + + // Modify injector context + event.injectorContext.set(SomeService, new SomeService()); +}); +``` For example, suppose you want to catch the event when a controller is invoked. If a particular controller is to be invoked, we check if the user has access to it. If the user has access, we continue. But if not, we jump to the next workflow item `accessDenied`. There, the procedure of an access-denied is then automatically processed further. diff --git a/website/src/pages/documentation/http/getting-started.md b/website/src/pages/documentation/http/getting-started.md index 195fcdc3f..b92f269e0 100644 --- a/website/src/pages/documentation/http/getting-started.md +++ b/website/src/pages/documentation/http/getting-started.md @@ -1,15 +1,39 @@ # Getting Started -Since Deepkit HTTP is based on Runtime Types, it is necessary to have Runtime Types already installed correctly. See [Runtime Type Installation](../runtime-types/getting-started.md). +Deepkit HTTP is a modern, type-safe HTTP framework that leverages TypeScript's type system to provide automatic validation, serialization, and powerful development tools. Unlike traditional HTTP frameworks, Deepkit HTTP uses runtime type information to automatically handle request/response processing, making your applications more robust and developer-friendly. -If this is done successfully, `@deepkit/app` can be installed or the Deepkit framework which already uses the library under the hood. +## Key Features + +- **Type-Safe by Design**: Automatic validation and serialization based on TypeScript types +- **Zero Configuration**: No need for manual validation schemas or serialization setup +- **Flexible Architecture**: Support both functional and class-based (controller) APIs +- **Powerful Event System**: Hook into any part of the HTTP request lifecycle +- **Built-in Testing**: Comprehensive testing utilities without starting actual servers +- **Dependency Injection**: Full DI support with HTTP-scoped providers + +## Prerequisites + +Since Deepkit HTTP is built on Runtime Types, you need to have Runtime Types properly configured. This enables the framework to understand your TypeScript types at runtime for automatic validation and serialization. + +See [Runtime Type Installation](../runtime-types/getting-started.md) for detailed setup instructions. + +## Installation + +Install the HTTP package: ```sh npm install @deepkit/http ``` -Note that `@deepkit/http` for the controller API is based on TypeScript annotations and this feature must be enabled accordingly with `experimentalDecorators` once the controller API is used. -If you don't use classes, you don't need to enable this feature. +Or use the full Deepkit Framework which includes HTTP functionality: + +```sh +npm install @deepkit/framework +``` + +## TypeScript Configuration + +For the controller API (class-based routes), you need to enable experimental decorators in your TypeScript configuration: _File: tsconfig.json_ @@ -25,11 +49,31 @@ _File: tsconfig.json_ } ``` -Once the library is installed, the API of it can be used directly. +**Note**: If you only use the functional API (function-based routes), experimental decorators are not required. + +## Architecture Overview + +Deepkit HTTP provides two complementary APIs for building HTTP applications: + +1. **Functional API**: Simple functions registered with a router - great for microservices and simple APIs +2. **Controller API**: Class-based controllers with decorators - ideal for larger applications with complex routing + +Both APIs share the same underlying features: automatic validation, serialization, dependency injection, and event system. ## Functional API -The functional API is based on functions and can be registered via the router registry, which can be obtained via the DI container of the app. +The functional API provides a simple, straightforward way to define HTTP routes using plain functions. This approach is perfect for microservices, simple APIs, or when you prefer a more functional programming style. + +### Why Use the Functional API? + +- **Simplicity**: Direct function-to-route mapping with minimal boilerplate +- **Flexibility**: Easy to organize routes across multiple files +- **Testing**: Functions are easy to unit test in isolation +- **Performance**: Minimal overhead with direct function calls + +### Basic Functional Routes + +Routes are registered through the `HttpRouterRegistry`, which you can access via the dependency injection container: ```typescript import { App } from '@deepkit/app'; @@ -42,138 +86,287 @@ const app = new App({ const router = app.get(HttpRouterRegistry); +// Simple GET route router.get('/', () => { return "Hello World!"; }); +// Route with path parameters - automatically typed and validated +router.get('/users/:id', (id: number) => { + return { userId: id, name: `User ${id}` }; +}); + +// POST route with request body - automatically validated +router.post('/users', (userData: { name: string; email: string }) => { + return { message: 'User created', data: userData }; +}); + app.run(); ``` -Once modules are used, functional routes can also be provided dynamically by modules. +### Module-Based Route Organization + +For larger applications, you can organize routes within modules. This provides better structure and allows for modular development: ```typescript import { App, createModuleClass } from '@deepkit/app'; import { FrameworkModule } from '@deepkit/framework'; import { HttpRouterRegistry } from '@deepkit/http'; -class MyModule extends createModuleClass({}) { +class ApiModule extends createModuleClass({}) { override process() { + // Configure routes when the module is processed this.configureProvider(router => { - router.get('/', () => { - return "Hello World!"; + router.get('/api/health', () => { + return { status: 'healthy', timestamp: new Date() }; + }); + + router.get('/api/version', () => { + return { version: '1.0.0' }; }); }); } } const app = new App({ - imports: [new FrameworkModule, new MyModule] + imports: [new FrameworkModule, new ApiModule] }); ``` -See [Framework Modules](../app/modules), to learn more about App Modules. +This modular approach allows you to: +- **Separate concerns**: Group related routes together +- **Reuse modules**: Share common functionality across applications +- **Conditional loading**: Load modules based on configuration +- **Better testing**: Test modules in isolation + +See [Framework Modules](../app/modules) to learn more about App Modules. ## Controller API -The controller API is based on classes and can be registered via the App-API under the option `controllers`. +The controller API uses classes with decorators to define HTTP routes. This approach is ideal for larger applications where you need better organization, shared state, dependency injection, and more structured routing. + +### Why Use the Controller API? + +- **Organization**: Group related routes in logical classes +- **Dependency Injection**: Easy access to services via constructor injection +- **Shared State**: Controllers can maintain state across requests +- **Middleware**: Apply middleware at the controller or method level +- **Inheritance**: Share common functionality through class inheritance +- **Metadata**: Rich decorator-based configuration + +### Basic Controller + +Controllers are classes with methods decorated with HTTP verb decorators: ```typescript import { App } from '@deepkit/app'; import { FrameworkModule } from '@deepkit/framework'; import { http } from '@deepkit/http'; -class MyPage { +class UserController { @http.GET('/') - helloWorld() { - return "Hello World!"; + welcome() { + return "Welcome to the User API!"; + } + + @http.GET('/users/:id') + getUser(id: number) { + // Path parameter 'id' is automatically extracted and validated as number + return { id, name: `User ${id}`, email: `user${id}@example.com` }; + } + + @http.POST('/users') + createUser(userData: { name: string; email: string }) { + // Request body is automatically validated against the type + return { message: 'User created', data: userData }; + } +} + +new App({ + controllers: [UserController], + imports: [new FrameworkModule] +}).run(); +``` + +### Controller with Dependencies + +Controllers can inject services through their constructor, making them powerful for complex business logic: + +```typescript +class UserService { + async findUser(id: number) { + // Database logic here + return { id, name: `User ${id}` }; + } +} + +class UserController { + constructor(private userService: UserService) {} + + @http.GET('/users/:id') + async getUser(id: number) { + return await this.userService.findUser(id); } } new App({ - controllers: [MyPage], + controllers: [UserController], + providers: [UserService], imports: [new FrameworkModule] }).run(); ``` -Once modules are used, controllers can also be provided by modules. +### Module-Based Controllers + +Controllers can be organized within modules for better structure: ```typescript import { App, createModuleClass } from '@deepkit/app'; import { FrameworkModule } from '@deepkit/framework'; import { http } from '@deepkit/http'; -class MyPage { - @http.GET('/') - helloWorld() { - return "Hello World!"; +class ApiController { + @http.GET('/api/status') + getStatus() { + return { status: 'running', uptime: process.uptime() }; } } -class MyModule extends createModuleClass({}) { +class ApiModule extends createModuleClass({}) { override process() { - this.addController(MyPage); + this.addController(ApiController); } } const app = new App({ - imports: [new FrameworkModule, new MyModule] + imports: [new FrameworkModule, new ApiModule] }); ``` -To provide controllers dynamically (depending on the configuration option, for example), the `process` hook can be used. +### Dynamic Controller Registration + +Controllers can be registered conditionally based on configuration: ```typescript class MyModuleConfiguration { debug: boolean = false; + enableAdmin: boolean = false; } class MyModule extends createModuleClass({ config: MyModuleConfiguration }) { override process() { + // Always add the main controller + this.addController(MainController); + + // Conditionally add debug controller if (this.config.debug) { class DebugController { - @http.GET('/debug/') - root() { - return 'Hello Debugger'; + @http.GET('/debug/info') + getDebugInfo() { + return { + environment: process.env.NODE_ENV, + memory: process.memoryUsage(), + uptime: process.uptime() + }; } } this.addController(DebugController); } + + // Conditionally add admin controller + if (this.config.enableAdmin) { + this.addController(AdminController); + } } } ``` -See [Framework Modules](../app/modules), to learn more about App Modules. +This pattern allows you to: +- **Feature flags**: Enable/disable entire controller sets +- **Environment-specific routes**: Different routes for development vs production +- **Modular architecture**: Load controllers based on installed modules + +See [Framework Modules](../app/modules) to learn more about App Modules. + +## HTTP Server Integration + +Deepkit HTTP is designed to work seamlessly with different server setups. Understanding how the HTTP server integration works helps you choose the right approach for your application. + +### Using Deepkit Framework (Recommended) + +When using the full Deepkit Framework, an HTTP server is automatically configured and managed for you. This is the easiest and most feature-complete approach: + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; + +const app = new App({ + controllers: [UserController], + imports: [new FrameworkModule] +}); + +// Server automatically starts on app.run() +app.run(); // Defaults to http://localhost:8080 +``` + +The framework handles: +- **Server lifecycle**: Automatic startup and graceful shutdown +- **Configuration**: Port, host, and other server settings +- **Error handling**: Proper error responses and logging +- **Performance**: Optimized request processing -## HTTP Server +### Standalone HTTP Server -If Deepkit Framework is used, an HTTP server is already built in. However, the HTTP library can also be used with its own HTTP server without using the Deepkit framework. +For more control or integration with existing Node.js applications, you can use Deepkit HTTP with a custom server: ```typescript import { Server } from 'http'; -import { HttpRequest, HttpResponse } from '@deepkit/http'; +import { App } from '@deepkit/app'; +import { HttpModule, HttpKernel, HttpRequest, HttpResponse } from '@deepkit/http'; const app = new App({ - controllers: [MyPage], - imports: [new HttpModule] + controllers: [UserController], + imports: [new HttpModule] // Note: HttpModule instead of FrameworkModule }); const httpKernel = app.get(HttpKernel); -new Server( - { IncomingMessage: HttpRequest, ServerResponse: HttpResponse, }, - ((req, res) => { +const server = new Server( + { + IncomingMessage: HttpRequest, + ServerResponse: HttpResponse + }, + (req, res) => { httpKernel.handleRequest(req as HttpRequest, res as HttpResponse); - }) -).listen(8080, () => { - console.log('listen at 8080'); + } +); + +server.listen(8080, () => { + console.log('Server listening on http://localhost:8080'); }); ``` -## Testing +This approach gives you: +- **Full control**: Manage server lifecycle yourself +- **Custom configuration**: Set up HTTPS, clustering, etc. +- **Integration**: Embed in existing applications +- **Flexibility**: Use with other HTTP frameworks + +## Testing Your HTTP Application + +One of Deepkit HTTP's greatest strengths is its built-in testing capabilities. You can test your entire HTTP application without starting an actual server, making tests fast, reliable, and easy to write. + +### Why Deepkit's Testing is Powerful -Deepkit HTTP provides comprehensive testing utilities that allow you to test your HTTP applications without starting an actual server: +- **No Server Required**: Tests run in-memory without network overhead +- **Full Integration**: Test the complete request/response cycle +- **Type Safety**: Same type checking in tests as in production +- **Fast Execution**: No network latency or port conflicts +- **Easy Mocking**: Simple dependency injection for test doubles + +### Basic Testing Example ```typescript import { expect, test } from '@jest/globals'; @@ -185,6 +378,11 @@ class UserController { getUser(id: number) { return { id, name: `User ${id}` }; } + + @http.POST('/users') + createUser(userData: { name: string; email: string }) { + return { id: 123, ...userData }; + } } test('user controller', async () => { @@ -192,28 +390,94 @@ test('user controller', async () => { controllers: [UserController] }); - const response = await testing.request(HttpRequest.GET('/users/42')); - expect(response.statusCode).toBe(200); - expect(response.json).toEqual({ id: 42, name: 'User 42' }); + // Test GET request with path parameter + const getResponse = await testing.request(HttpRequest.GET('/users/42')); + expect(getResponse.statusCode).toBe(200); + expect(getResponse.json).toEqual({ id: 42, name: 'User 42' }); + + // Test POST request with JSON body + const postResponse = await testing.request( + HttpRequest.POST('/users').json({ name: 'John', email: 'john@example.com' }) + ); + expect(postResponse.statusCode).toBe(200); + expect(postResponse.json.name).toBe('John'); }); ``` -For more comprehensive testing examples, see the [Testing](testing.md) documentation. +### Testing with Dependencies + +```typescript +class UserService { + async findUser(id: number) { + // In real app, this would query a database + return { id, name: `User ${id}`, email: `user${id}@example.com` }; + } +} -## HTTP Client +class MockUserService { + async findUser(id: number) { + return { id, name: `Test User ${id}`, email: `test${id}@example.com` }; + } +} -Deepkit HTTP provides utilities for making HTTP requests with automatic validation and type casting: +test('controller with mocked dependencies', async () => { + const testing = createTestingApp({ + controllers: [UserController], + providers: [ + { provide: UserService, useClass: MockUserService } + ] + }); + + const response = await testing.request(HttpRequest.GET('/users/1')); + expect(response.json.name).toBe('Test User 1'); +}); +``` + +The testing system automatically handles: +- **Request parsing**: Body, headers, query parameters +- **Validation**: Same validation as production +- **Dependency injection**: Full DI container with mocking support +- **Response serialization**: JSON, HTML, and custom responses + +For comprehensive testing strategies and advanced patterns, see the [Testing](testing.md) documentation. + +## HTTP Client Utilities + +Deepkit HTTP includes powerful client utilities for making HTTP requests, particularly useful for testing and inter-service communication. These utilities provide a fluent API with automatic type handling. + +### Why Use Deepkit's HTTP Client? + +- **Type Safety**: Automatic serialization and validation +- **Fluent API**: Chainable methods for building requests +- **Testing Integration**: Perfect for integration tests +- **Consistent Interface**: Same patterns as server-side code + +### Making HTTP Requests ```typescript import { HttpRequest } from '@deepkit/http'; -// Create requests with fluent API -const request = HttpRequest.POST('/api/users') +// Simple GET request +const getRequest = HttpRequest.GET('/api/users/123'); + +// POST request with JSON body +const postRequest = HttpRequest.POST('/api/users') .json({ name: 'John', email: 'john@example.com' }) - .header('Authorization', 'Bearer token123') - .build(); + .header('Authorization', 'Bearer token123'); + +// PUT request with custom headers +const putRequest = HttpRequest.PUT('/api/users/123') + .json({ name: 'John Updated' }) + .header('Content-Type', 'application/json') + .header('X-API-Version', '2.0'); +``` -// For file uploads +### File Uploads + +The client supports multipart form data for file uploads: + +```typescript +// Single file upload const uploadRequest = HttpRequest.POST('/upload') .multiPart([ { @@ -226,38 +490,147 @@ const uploadRequest = HttpRequest.POST('/upload') value: 'Important document' } ]); + +// Multiple files with metadata +const multiUploadRequest = HttpRequest.POST('/upload-multiple') + .multiPart([ + { + name: 'files', + file: Buffer.from('first file'), + fileName: 'file1.txt' + }, + { + name: 'files', + file: Buffer.from('second file'), + fileName: 'file2.txt' + }, + { + name: 'category', + value: 'documents' + } + ]); ``` -## Route Names +### Using in Tests -Routes can be given a unique name that can be referenced when forwarding. Depending on the API, the way a name is defined differs. +The HTTP client is particularly powerful when combined with the testing utilities: ```typescript -//functional API +test('API integration', async () => { + const testing = createTestingApp({ controllers: [ApiController] }); + + // Test the complete request/response cycle + const response = await testing.request( + HttpRequest.POST('/api/users') + .json({ name: 'Test User', email: 'test@example.com' }) + .header('Authorization', 'Bearer test-token') + ); + + expect(response.statusCode).toBe(201); + expect(response.json.name).toBe('Test User'); +}); +``` + +## Route Names and URL Generation + +Route names provide a powerful way to reference routes throughout your application without hardcoding URLs. This makes your application more maintainable and allows for easy URL changes without breaking references. + +### Why Use Route Names? + +- **Maintainability**: Change URLs in one place without breaking references +- **Type Safety**: Generate URLs with compile-time parameter checking +- **Consistency**: Ensure all URL references are valid +- **Refactoring**: Safely rename routes across your application + +### Defining Named Routes + +Route names can be defined differently depending on which API you're using: + +```typescript +// Functional API - using route configuration object router.get({ path: '/user/:id', name: 'userDetail' }, (id: number) => { - return {userId: id}; + return { userId: id, name: `User ${id}` }; +}); + +router.post({ + path: '/user/:id/posts', + name: 'createUserPost' +}, (id: number, postData: { title: string; content: string }) => { + return { message: 'Post created', userId: id }; }); -//controller API +// Controller API - using the name() method class UserController { @http.GET('/user/:id').name('userDetail') userDetail(id: number) { - return {userId: id}; + return { userId: id, name: `User ${id}` }; + } + + @http.GET('/user/:id/posts').name('userPosts') + getUserPosts(id: number) { + return { posts: [], userId: id }; } } ``` -From all routes with a name the URL can be requested by `Router.resolveUrl()`. +### Generating URLs from Route Names + +Once routes are named, you can generate URLs programmatically using the `HttpRouter`: ```typescript import { HttpRouter } from '@deepkit/http'; -const router = app.get(HttpRouter); -router.resolveUrl('userDetail', {id: 2}); //=> '/user/2' + +class LinkService { + constructor(private router: HttpRouter) {} + + getUserProfileUrl(userId: number): string { + return this.router.resolveUrl('userDetail', { id: userId }); + // Returns: '/user/123' + } + + getUserPostsUrl(userId: number): string { + return this.router.resolveUrl('userPosts', { id: userId }); + // Returns: '/user/123/posts' + } +} ``` +### Using Route Names in Controllers + +Route names are particularly useful for redirects and generating links in responses: + +```typescript +import { Redirect } from '@deepkit/http'; + +class UserController { + @http.POST('/users').name('createUser') + createUser(userData: { name: string; email: string }) { + const newUser = { id: 123, ...userData }; + + // Redirect to the user detail page using route name + return Redirect.toRoute('userDetail', { id: newUser.id }); + } + + @http.GET('/users/:id').name('userDetail') + getUser(id: number, router: HttpRouter) { + const user = { id, name: `User ${id}` }; + + return { + ...user, + links: { + self: router.resolveUrl('userDetail', { id }), + posts: router.resolveUrl('userPosts', { id }) + } + }; + } +} +``` + +This approach creates a robust, maintainable URL structure throughout your application. + ## Error Handling Deepkit HTTP provides built-in error classes for common HTTP errors: @@ -320,12 +693,91 @@ const app = new App({ }); ``` +## Choosing Between Functional and Controller APIs + +Both APIs have their strengths and can even be used together in the same application: + +### Use Functional API When: +- Building microservices or simple APIs +- You prefer functional programming patterns +- Routes are simple and don't share much logic +- You want minimal boilerplate +- Building serverless functions + +### Use Controller API When: +- Building larger applications with complex routing +- You need shared state or logic across routes +- You want to group related functionality +- You prefer object-oriented patterns +- You need extensive middleware or decorators + +### Hybrid Approach +```typescript +const app = new App({ + controllers: [UserController, AdminController], // Complex features + imports: [new FrameworkModule] +}); + +const router = app.get(HttpRouterRegistry); + +// Simple utility routes as functions +router.get('/health', () => ({ status: 'ok', timestamp: new Date() })); +router.get('/version', () => ({ version: '1.0.0' })); +``` + ## Next Steps -- **[Input & Output](input-output.md)**: Learn about handling request data and responses +Now that you understand the basics, explore these topics to build production-ready applications: + +- **[Input & Output](input-output.md)**: Master request data handling, validation, and response types - **[Security](security.md)**: Implement authentication, authorization, and security best practices -- **[Middleware](middleware.md)**: Add custom middleware for cross-cutting concerns -- **[Events](events.md)**: Hook into the HTTP request lifecycle with events +- **[Middleware](middleware.md)**: Add cross-cutting concerns like logging, CORS, and rate limiting +- **[Events](events.md)**: Hook into the HTTP request lifecycle for advanced customization - **[Testing](testing.md)**: Write comprehensive tests for your HTTP applications -- **[Dependency Injection](dependency-injection.md)**: Use DI for better code organization +- **[Dependency Injection](dependency-injection.md)**: Leverage DI for clean, maintainable code +- **[Views](views.md)**: Build server-side rendered HTML with type-safe JSX templates + +## Quick Reference + +### Common Patterns + +```typescript +// Path parameters with validation +router.get('/users/:id', (id: number & Positive) => { /* ... */ }); + +// Query parameters with defaults +router.get('/posts', (page: HttpQuery = 1) => { /* ... */ }); + +// Request body validation +router.post('/users', (userData: HttpBody) => { /* ... */ }); + +// Dependency injection +router.get('/data', (database: Database, logger: Logger) => { /* ... */ }); + +// Error handling +router.get('/risky', () => { + if (Math.random() > 0.5) { + throw new HttpBadRequestError('Random error'); + } + return { success: true }; +}); +``` + +### Essential Imports + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; +import { + http, + HttpRouterRegistry, + HttpBody, + HttpQuery, + HttpRequest, + HttpResponse, + HttpBadRequestError, + HttpUnauthorizedError, + HttpNotFoundError +} from '@deepkit/http'; +``` diff --git a/website/src/pages/documentation/http/input-output.md b/website/src/pages/documentation/http/input-output.md index ab96beee3..69de421f5 100644 --- a/website/src/pages/documentation/http/input-output.md +++ b/website/src/pages/documentation/http/input-output.md @@ -1,106 +1,317 @@ # Input & Output -The input and output of an HTTP route is the data that is sent to the server and the data that is sent back to the client. This includes the path parameters, query parameters, body, headers, and the response itself. In this chapter, we will look at how to read, deserialize, validate, and write data in an HTTP route. +HTTP applications are fundamentally about processing input data from clients and returning appropriate output. Deepkit HTTP revolutionizes this process by leveraging TypeScript's type system to provide automatic validation, serialization, and type safety throughout the entire request/response cycle. -## Input +## Understanding Deepkit's Type-Driven Approach -All the following input variations function in the same way for both the functional and the controller API. They allow data to be read from an HTTP request in a typesafe and decoupled manner. This not only leads to significantly increased security, but also simplifies unit testing, since strictly speaking, not even an HTTP request object needs to exist to test the route. +Traditional HTTP frameworks require you to manually parse, validate, and convert request data. Deepkit HTTP eliminates this boilerplate by using TypeScript types as the source of truth for data processing. This approach provides several key benefits: -All parameters are automatically converted (deserialized) to the defined TypeScript type and validated. This is done via Deepkit Runtime Types and its [Serialization](../runtime-types/serialization.md) and [Validation](../runtime-types/validation) features. +### Automatic Data Processing +- **Deserialization**: Convert string-based HTTP data to proper TypeScript types +- **Validation**: Ensure data meets your type constraints before reaching your business logic +- **Type Safety**: Compile-time guarantees that your data matches expected types +- **Error Handling**: Automatic HTTP error responses for invalid data -For simplicity, all examples with the functional API are shown below. +### Security by Design +Type-driven validation means malformed or malicious data is rejected before it reaches your application logic. This significantly reduces security vulnerabilities and makes your application more robust. -### Path Parameters +### Simplified Testing +Since route functions work with pure TypeScript types rather than HTTP-specific objects, you can test your business logic without creating mock HTTP requests. This leads to faster, more focused unit tests. + +## Input Processing + +Deepkit HTTP can extract and validate data from multiple sources in an HTTP request: + +- **Path Parameters**: Values extracted from URL segments (e.g., `/users/:id`) +- **Query Parameters**: Key-value pairs from the URL query string (e.g., `?page=1&limit=10`) +- **Request Body**: JSON, form data, or multipart data sent in the request body +- **Headers**: HTTP headers for authentication, content negotiation, etc. +- **Files**: Uploaded files through multipart form data + +All input types work identically with both the functional and controller APIs. The examples below use the functional API for simplicity, but the same patterns apply to controller methods. + +### How Type Conversion Works -Path parameters are values extracted from the URL of the route. The type of the value depends on the type at the associated parameter of the function or method. The conversion is done automatically with the feature [Soft Type Conversion](../runtime-types/serialization#soft-type-conversion). +Deepkit uses "soft type conversion" to intelligently convert HTTP string data to your TypeScript types: ```typescript -router.get('/:text', (text: string) => { - return 'Hello ' + text; +// URL: /users/123 +router.get('/users/:id', (id: number) => { + // 'id' is automatically converted from string "123" to number 123 + console.log(typeof id); // "number" + return { userId: id }; }); ``` -```sh -$ curl http://localhost:8080/galaxy -Hello galaxy -``` +This conversion is powered by Deepkit's [Runtime Types](../runtime-types/serialization.md) system, which understands your TypeScript types at runtime and can safely convert between different representations. + +### Path Parameters + +Path parameters extract values from URL segments and are one of the most common ways to pass data to HTTP routes. Deepkit automatically extracts these values and converts them to the appropriate TypeScript types. -If a Path parameter is defined as a type other than string, it will be converted correctly. +#### Basic Path Parameters ```typescript -router.get('/user/:id', (id: number) => { - return `${id} ${typeof id}`; +// Simple string parameter +router.get('/hello/:name', (name: string) => { + return `Hello ${name}!`; +}); + +// Numeric parameter with automatic conversion +router.get('/users/:id', (id: number) => { + // The string "123" from URL becomes the number 123 + console.log(typeof id); // "number" + return { userId: id, name: `User ${id}` }; +}); + +// Boolean parameter +router.get('/posts/:published', (published: boolean) => { + // URL values "true"/"false" become boolean true/false + return { showPublished: published }; }); ``` -```sh -$ curl http://localhost:8080/user/23 -23 number +#### Multiple Path Parameters + +```typescript +router.get('/users/:userId/posts/:postId', (userId: number, postId: number) => { + return { + user: userId, + post: postId, + url: `/users/${userId}/posts/${postId}` + }; +}); ``` -Additional validation constraints can also be applied to the types. +#### Path Parameter Validation + +Add validation constraints to ensure path parameters meet your requirements: ```typescript -import { Positive } from '@deepkit/type'; +import { Positive, MinLength, MaxLength } from '@deepkit/type'; -router.get('/user/:id', (id: number & Positive) => { - return `${id} ${typeof id}`; +// Ensure ID is a positive number +router.get('/users/:id', (id: number & Positive) => { + return { userId: id }; +}); + +// Validate string length +router.get('/categories/:slug', (slug: string & MinLength<3> & MaxLength<50>) => { + return { category: slug }; +}); + +// Combine multiple constraints +router.get('/products/:id', (id: number & Positive & Maximum<999999>) => { + return { productId: id }; }); ``` -All validation types from `@deepkit/type` can be applied. For more on this, see [HTTP Validation](#validation). +When validation fails, Deepkit automatically returns a `400 Bad Request` response with details about the validation error. + +#### Advanced Path Parameter Patterns -The Path parameters have `[^]+` set as a regular expression by default in the URL matching. The RegExp for this can be customized as follows: +For complex URL patterns, you can customize the regular expression used for matching: ```typescript import { HttpRegExp } from '@deepkit/http'; -import { Positive } from '@deepkit/type'; -router.get('/user/:id', (id: HttpRegExp) => { - return `${id} ${typeof id}`; +// Only match numeric IDs +router.get('/users/:id', (id: HttpRegExp) => { + return { userId: id }; +}); + +// Match specific patterns (e.g., UUIDs) +router.get('/resources/:uuid', ( + uuid: HttpRegExp +) => { + return { resourceId: uuid }; }); ``` -This is only necessary in exceptional cases, because often the types in combination with validation types themselves already correctly restrict possible values. +**Note**: Custom regular expressions are rarely needed since TypeScript types combined with validation constraints usually provide sufficient control over acceptable values. + +#### Path Parameter Best Practices + +1. **Use descriptive names**: `userId` instead of `id` when the context isn't clear +2. **Apply validation**: Always validate numeric IDs with `Positive` +3. **Keep URLs simple**: Avoid too many path parameters in a single route +4. **Use consistent patterns**: Establish URL conventions across your application + +```typescript +// Good: Clear, validated, consistent +router.get('/users/:userId', (userId: number & Positive) => { /* ... */ }); +router.get('/users/:userId/posts/:postId', (userId: number & Positive, postId: number & Positive) => { /* ... */ }); + +// Avoid: Unclear, unvalidated +router.get('/data/:x/:y/:z', (x: any, y: any, z: any) => { /* ... */ }); +``` ### Query Parameters -Query parameters are values from the URL after the `?` character and can be read with the `HttpQuery` type. The name of the parameter corresponds to the name of the query parameter. +Query parameters provide a flexible way to pass optional data and configuration to your routes. They appear after the `?` in URLs and are commonly used for filtering, pagination, sorting, and other optional parameters. + +#### Understanding Query Parameters + +Query parameters are key-value pairs in the URL query string: +- `?page=1&limit=10` - Multiple parameters +- `?search=typescript` - Single parameter +- `?tags=web&tags=api` - Array parameters (same key multiple times) + +#### Basic Query Parameters + +Use the `HttpQuery` type to extract and validate individual query parameters: ```typescript import { HttpQuery } from '@deepkit/http'; -router.get('/', (text: HttpQuery) => { - return `Hello ${text}`; +// Single query parameter with type conversion +router.get('/search', (query: HttpQuery) => { + return { searchTerm: query, results: [] }; }); -``` -```sh -$ curl http://localhost:8080/\?text\=galaxy -Hello galaxy +// Multiple query parameters +router.get('/posts', ( + page: HttpQuery = 1, + limit: HttpQuery = 10 +) => { + return { + posts: [], + pagination: { page, limit, total: 0 } + }; +}); ``` -Query parameters are also automatically deserialized and validated. +#### Query Parameter Validation + +Apply validation constraints to ensure query parameters meet your requirements: ```typescript import { HttpQuery } from '@deepkit/http'; -import { MinLength } from '@deepkit/type'; +import { MinLength, Positive, Maximum } from '@deepkit/type'; + +router.get('/search', ( + // Search term must be at least 3 characters + query: HttpQuery>, + // Page must be positive + page: HttpQuery = 1, + // Limit must be between 1 and 100 + limit: HttpQuery> = 10 +) => { + return { + searchTerm: query, + page, + limit, + results: [] + }; +}); +``` -router.get('/', (text: HttpQuery & MinLength<3>) => { - return 'Hello ' + text; -} +#### Optional vs Required Query Parameters + +```typescript +// Required query parameter (no default value) +router.get('/filter', (category: HttpQuery) => { + return { category, items: [] }; +}); + +// Optional query parameter (with default value) +router.get('/posts', ( + published: HttpQuery = true, + sortBy: HttpQuery<'date' | 'title' | 'author'> = 'date' +) => { + return { published, sortBy, posts: [] }; +}); ``` -```sh -$ curl http://localhost:8080/\?text\=galaxy -Hello galaxy -$ curl http://localhost:8080/\?text\=ga -error +#### Array Query Parameters + +Handle multiple values for the same query parameter: + +```typescript +// URL: /posts?tags=web&tags=api&tags=typescript +router.get('/posts', (tags: HttpQuery = []) => { + return { + selectedTags: tags, + posts: [] // Filter posts by tags + }; +}); + +// URL: /products?ids=1&ids=2&ids=3 +router.get('/products', (ids: HttpQuery = []) => { + return { + requestedIds: ids, + products: [] // Fetch products by IDs + }; +}); +``` + +#### Complex Query Parameter Types + +```typescript +// Enum-like string unions +router.get('/posts', ( + status: HttpQuery<'draft' | 'published' | 'archived'> = 'published' +) => { + return { status, posts: [] }; +}); + +// Date parameters (automatically parsed) +router.get('/events', ( + startDate: HttpQuery, + endDate: HttpQuery +) => { + return { + dateRange: { start: startDate, end: endDate }, + events: [] + }; +}); +``` + +#### Security Considerations + +**Important**: Query parameter values are not automatically escaped or sanitized. When returning user input directly in HTML responses, you risk XSS vulnerabilities: + +```typescript +// ❌ DANGEROUS - Direct output without escaping +router.get('/search', (query: HttpQuery) => { + return `

Results for: ${query}

`; // XSS vulnerability! +}); + +// ✅ SAFE - Use JSON responses or proper escaping +router.get('/search', (query: HttpQuery) => { + return { searchTerm: query, results: [] }; // JSON is safe +}); + +// ✅ SAFE - Proper HTML escaping when needed +import { escapeHtml } from 'some-escaping-library'; +router.get('/search', (query: HttpQuery) => { + return `

Results for: ${escapeHtml(query)}

`; +}); ``` -All validation types from `@deepkit/type` can be applied. For more on this, see [HTTP Validation](#validation). +#### Query Parameter Best Practices -Warning: Parameter values are not escaped/sanitized. Their direct return in a string in a route as HTML opens a security hole (XSS). Make sure that external input is never trusted and filtere/sanitize/convert data where necessary. +1. **Use defaults**: Provide sensible defaults for optional parameters +2. **Validate input**: Always validate query parameters, especially for pagination +3. **Limit arrays**: Set maximum lengths for array parameters to prevent abuse +4. **Document parameters**: Make it clear which parameters are required vs optional +5. **Consistent naming**: Use consistent parameter names across your API + +```typescript +// Good example with validation and defaults +router.get('/api/posts', ( + page: HttpQuery> = 1, + limit: HttpQuery> = 20, + search: HttpQuery & MaxLength<100>> = '', + tags: HttpQuery> = [] +) => { + return { + posts: [], + pagination: { page, limit, total: 0 }, + filters: { search, tags } + }; +}); +``` ### Query Model diff --git a/website/src/pages/documentation/http/middleware.md b/website/src/pages/documentation/http/middleware.md index 131ef3e4f..e7df66134 100644 --- a/website/src/pages/documentation/http/middleware.md +++ b/website/src/pages/documentation/http/middleware.md @@ -1,70 +1,241 @@ # Middleware -HTTP middlewares allow you to hook into the request/response cycle as an alternative to HTTP events. Its API allows you to use all middlewares from the Express/Connect framework. +Middleware provides a powerful way to intercept and modify HTTP requests and responses as they flow through your application. Think of middleware as a pipeline where each piece can inspect, modify, or even stop the request before it reaches your route handlers. -A middleware can either be a class (which is instantiated by the dependency injection container) or a simple function. +## Understanding Middleware + +Middleware functions execute in sequence during the request/response cycle. Each middleware can: + +- **Inspect requests**: Log, authenticate, validate headers +- **Modify requests**: Add data, transform headers, parse custom formats +- **Control flow**: Stop processing, redirect, or continue to the next middleware +- **Modify responses**: Add headers, transform data, handle errors +- **Perform side effects**: Logging, metrics, caching + +### The Middleware Pipeline + +``` +Request → Middleware 1 → Middleware 2 → Route Handler → Response + ↓ ↓ ↓ + Logging Authentication Business Logic +``` + +Each middleware calls `next()` to pass control to the next middleware in the chain. If `next()` is not called, the request stops at that middleware. + +## Middleware Types + +Deepkit HTTP supports two types of middleware: + +### Class-Based Middleware (Recommended) + +Class-based middleware integrates with Deepkit's dependency injection system, making it easy to inject services and maintain state: ```typescript import { HttpMiddleware, httpMiddleware, HttpRequest, HttpResponse } from '@deepkit/http'; -class MyMiddleware implements HttpMiddleware { +class LoggingMiddleware implements HttpMiddleware { + constructor(private logger: Logger) {} // Dependency injection + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { - response.setHeader('middleware', '1'); + const startTime = Date.now(); + + this.logger.info(`${request.method} ${request.url} - Started`); + + // Continue to next middleware/route next(); + + // This runs after the response is sent + const duration = Date.now() - startTime; + this.logger.info(`${request.method} ${request.url} - ${response.statusCode} - ${duration}ms`); } } +``` +### Function-Based Middleware -function myMiddlewareFunction(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { - response.setHeader('middleware', '1'); - next(); +Simple functions work well for basic middleware that doesn't need dependency injection: + +```typescript +function corsMiddleware(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); + response.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (request.method === 'OPTIONS') { + response.statusCode = 200; + response.end(); + return; // Don't call next() - request is complete + } + + next(); // Continue to next middleware } +``` + +### Registering Middleware + +```typescript +import { App } from '@deepkit/app'; +import { FrameworkModule } from '@deepkit/framework'; new App({ - providers: [MyMiddleware], + providers: [LoggingMiddleware, Logger], // Register class-based middleware as provider middlewares: [ - httpMiddleware.for(MyMiddleware), - httpMiddleware.for(myMiddlewareFunction), + httpMiddleware.for(LoggingMiddleware), // Class-based + httpMiddleware.for(corsMiddleware), // Function-based ], imports: [new FrameworkModule] }).run(); ``` -## Global +## How Middleware Execution Works + +Understanding the execution flow is crucial for writing effective middleware: + +```typescript +class TimingMiddleware implements HttpMiddleware { + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + console.log('1. Before next()'); + + next(); // Control passes to next middleware/route + + console.log('4. After next() - response is ready'); + } +} + +class AuthMiddleware implements HttpMiddleware { + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + console.log('2. Auth middleware executing'); + + // Simulate authentication + if (!request.headers.authorization) { + response.statusCode = 401; + response.end('Unauthorized'); + return; // Don't call next() - stop here + } + + next(); // Continue to route handler + + console.log('3. Auth middleware cleanup'); + } +} + +// Route handler +router.get('/protected', () => { + console.log('Route handler executing'); + return { message: 'Protected data' }; +}); +``` + +**Execution order**: TimingMiddleware → AuthMiddleware → Route Handler → AuthMiddleware cleanup → TimingMiddleware cleanup + +## Middleware Scope and Targeting + +Deepkit HTTP provides flexible ways to control where middleware executes. You can apply middleware globally, to specific controllers, routes, or based on various criteria. -By using httpMiddleware.for(MyMiddleware) a middleware is registered for all routes, globally. +### Global Middleware + +Global middleware executes for every HTTP request in your application. This is perfect for cross-cutting concerns like logging, CORS, security headers, or authentication. ```typescript import { httpMiddleware } from '@deepkit/http'; +class SecurityHeadersMiddleware implements HttpMiddleware { + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + // Add security headers to all responses + response.setHeader('X-Frame-Options', 'DENY'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + response.setHeader('X-XSS-Protection', '1; mode=block'); + + next(); + } +} + new App({ - providers: [MyMiddleware], + providers: [SecurityHeadersMiddleware], middlewares: [ - httpMiddleware.for(MyMiddleware) + httpMiddleware.for(SecurityHeadersMiddleware) // Applies to ALL routes ], imports: [new FrameworkModule] }).run(); ``` -## Per Controller +**When to use global middleware:** +- Security headers that should be on every response +- Request logging and monitoring +- CORS handling +- Rate limiting +- Authentication checks (though you might want more targeted approaches) -You can limit middlewares to one or multiple controllers in two ways. Either by using the `@http.controller` or `httpMiddleware.for(T).forControllers()`. `excludeControllers` allow you to exclude controllers. +### Controller-Specific Middleware + +Apply middleware to specific controllers when you need functionality that's relevant to a particular area of your application. + +#### Using Controller Decorators ```typescript -@http.middleware(MyMiddleware) -class MyFirstController { +class AdminAuthMiddleware implements HttpMiddleware { + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + // Check for admin privileges + const user = await this.authenticateUser(request); + if (!user || !user.isAdmin) { + response.statusCode = 403; + response.end('Admin access required'); + return; + } + next(); + } } + +@http.middleware(AdminAuthMiddleware) +class AdminController { + @http.GET('/admin/users') + listUsers() { + return { users: [] }; + } + + @http.DELETE('/admin/users/:id') + deleteUser(id: number) { + return { deleted: id }; + } +} +``` + +#### Using Middleware Configuration + +```typescript +class ApiKeyMiddleware implements HttpMiddleware { + async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) { + const apiKey = request.headers['x-api-key']; + if (!apiKey || !this.isValidApiKey(apiKey)) { + response.statusCode = 401; + response.end('Valid API key required'); + return; + } + next(); + } +} + new App({ - providers: [MyMiddleware], - controllers: [MainController, UsersCommand], + providers: [ApiKeyMiddleware], + controllers: [PublicController, ApiController, AdminController], middlewares: [ - httpMiddleware.for(MyMiddleware).forControllers(MyFirstController, MySecondController) + // Apply API key middleware only to API controllers + httpMiddleware.for(ApiKeyMiddleware).forControllers(ApiController, AdminController), + + // Exclude specific controllers if needed + httpMiddleware.for(SomeMiddleware).excludeControllers(PublicController) ], imports: [new FrameworkModule] }).run(); ``` +**When to use controller-specific middleware:** +- Authentication/authorization for specific areas (admin, API, user areas) +- Different rate limiting for different controller types +- Specialized logging or monitoring for certain features +- Content negotiation for API vs web controllers + ## Per Route Name `forRouteNames` along with its counterpart `excludeRouteNames` allow you to filter the execution of a middleware per route names. diff --git a/website/src/pages/documentation/http/security.md b/website/src/pages/documentation/http/security.md index 1f9e197a2..51f301486 100644 --- a/website/src/pages/documentation/http/security.md +++ b/website/src/pages/documentation/http/security.md @@ -1,36 +1,111 @@ # Security -Deepkit HTTP provides comprehensive security features including authentication, authorization, session management, and protection against common web vulnerabilities. This chapter covers how to implement secure HTTP applications. +Security is paramount in web applications, and Deepkit HTTP provides a comprehensive security framework that integrates seamlessly with its type system and event-driven architecture. This chapter covers essential security concepts and practical implementations for building secure HTTP applications. + +## Security Fundamentals + +Web application security involves multiple layers of protection: + +### Defense in Depth +- **Input Validation**: Validate all incoming data at the application boundary +- **Authentication**: Verify user identity before granting access +- **Authorization**: Control what authenticated users can access +- **Data Protection**: Encrypt sensitive data in transit and at rest +- **Error Handling**: Prevent information leakage through error messages +- **Monitoring**: Log security events and detect suspicious activity + +### Deepkit's Security Advantages + +Deepkit HTTP's type-driven approach provides inherent security benefits: + +- **Automatic Validation**: TypeScript types automatically validate input data +- **Type Safety**: Compile-time guarantees prevent many runtime vulnerabilities +- **Event System**: Centralized security logic through HTTP workflow events +- **Dependency Injection**: Clean separation of security concerns +- **Testing**: Easy to test security logic in isolation ## Authentication -Authentication verifies the identity of users accessing your application. Deepkit HTTP supports various authentication methods through its event system and dependency injection. +Authentication is the process of verifying that users are who they claim to be. Deepkit HTTP provides flexible authentication mechanisms through its event system, allowing you to implement various authentication strategies. + +### Authentication Strategies + +Common authentication methods include: + +- **Token-based**: JWT, API keys, bearer tokens +- **Session-based**: Server-side sessions with cookies +- **Basic Authentication**: Username/password in headers (HTTPS only) +- **OAuth/OpenID**: Third-party authentication providers +- **Multi-factor**: Combining multiple authentication methods -### Basic Authentication with Headers +### How Authentication Works in Deepkit -You can implement authentication by listening to HTTP workflow events and checking authentication headers: +Authentication in Deepkit HTTP happens during the HTTP workflow, specifically in the `onAuth` event. This allows you to: + +1. **Intercept requests**: Check authentication before routes execute +2. **Set user context**: Make authenticated user available to routes +3. **Handle failures**: Return appropriate error responses +4. **Centralize logic**: Keep authentication logic in one place + +### Token-Based Authentication + +Token-based authentication is stateless and scalable, making it ideal for APIs and modern web applications. Here's how to implement it with Deepkit HTTP: + +#### Basic Token Authentication ```typescript import { App } from '@deepkit/app'; import { FrameworkModule } from '@deepkit/framework'; -import { http, httpWorkflow, HttpUnauthorizedError, HttpRequest } from '@deepkit/http'; -import { eventDispatcher } from '@deepkit/event'; +import { http, httpWorkflow, HttpUnauthorizedError } from '@deepkit/http'; class User { - constructor(public username: string, public id: number) {} + constructor( + public id: number, + public username: string, + public email: string, + public roles: string[] = [] + ) {} +} + +class AuthService { + private validTokens = new Map([ + ['token123', { id: 1, username: 'john', email: 'john@example.com', roles: ['user'] }], + ['token456', { id: 2, username: 'jane', email: 'jane@example.com', roles: ['user', 'admin'] }] + ]); + + validateToken(token: string): User | null { + const userData = this.validTokens.get(token); + if (!userData) return null; + + return new User(userData.id, userData.username, userData.email, userData.roles); + } } class UserController { @http.GET('/profile') getProfile(user: User) { - return { username: user.username, id: user.id }; + return { + id: user.id, + username: user.username, + email: user.email, + roles: user.roles + }; + } + + @http.GET('/protected-data') + getProtectedData(user: User) { + return { + message: 'This is protected data', + accessedBy: user.username, + timestamp: new Date() + }; } } const app = new App({ controllers: [UserController], - imports: [new FrameworkModule], providers: [ + AuthService, { provide: User, scope: 'http', @@ -38,36 +113,43 @@ const app = new App({ throw new Error('User must be set via injector context during authentication'); } } - ] + ], + imports: [new FrameworkModule] }); -// Authentication listener -app.listen(httpWorkflow.onAuth, (event) => { +// Authentication event listener +app.listen(httpWorkflow.onAuth, (event, authService: AuthService) => { const authHeader = event.request.headers.authorization; if (!authHeader) { throw new HttpUnauthorizedError('Authorization header required'); } - // Validate token (this is a simple example) - const validTokens = { - 'token123': { username: 'john', id: 1 }, - 'token456': { username: 'jane', id: 2 } - }; + // Extract token from "Bearer " format + const token = authHeader.startsWith('Bearer ') + ? authHeader.substring(7) + : authHeader; - const userData = validTokens[authHeader]; - if (!userData) { - throw new HttpUnauthorizedError('Invalid token'); + const user = authService.validateToken(token); + if (!user) { + throw new HttpUnauthorizedError('Invalid or expired token'); } - // Set user via injector context - const user = new User(userData.username, userData.id); + // Make user available to controllers via dependency injection event.injectorContext.set(User, user); }); app.run(); ``` +#### Why This Pattern Works + +1. **Centralized Authentication**: All authentication logic is in one event listener +2. **Type Safety**: The `User` object is properly typed throughout the application +3. **Dependency Injection**: Controllers receive the authenticated user automatically +4. **Error Handling**: Invalid tokens result in proper HTTP 401 responses +5. **Testability**: Easy to mock the `AuthService` for testing + ### JWT Authentication For JWT-based authentication, you can create a more sophisticated authentication system: diff --git a/website/src/pages/documentation/http/views.md b/website/src/pages/documentation/http/views.md index bba04e96e..c66872745 100644 --- a/website/src/pages/documentation/http/views.md +++ b/website/src/pages/documentation/http/views.md @@ -1,36 +1,55 @@ -# HTML Views +# HTML Views and JSX Templating -Deepkit HTTP comes with a built-in HTML view rendering system. It's based on JSX and allows you to write your views in TypeScript. It's not a template engine with its own syntax, but a full-fledged TypeScript/JSX renderer. +Deepkit HTTP includes a powerful, built-in HTML rendering system based on JSX (JavaScript XML). Unlike traditional template engines that require learning new syntax, Deepkit's approach lets you write HTML views using familiar TypeScript and JSX, providing type safety, performance, and developer productivity. -It optimises the JSX code at runtime and caches the result. It's therefore very fast and has almost no overhead. +## Why JSX for Server-Side Rendering? +### Advantages Over Traditional Templates -## JSX +- **Type Safety**: Full TypeScript support with compile-time error checking +- **Familiar Syntax**: If you know React or similar frameworks, you already know JSX +- **No New Language**: Pure TypeScript/JavaScript logic instead of template-specific syntax +- **IDE Support**: Full IntelliSense, refactoring, and debugging support +- **Component Reuse**: Build reusable UI components just like in frontend frameworks +- **Performance**: Optimized compilation and runtime caching -JSX is a syntax extension to JavaScript and comes with TypeScript support out of the box. It allows you to write HTML in TypeScript. It's very similar to Vue.js or React.js. +### How Deepkit's JSX Works -```tsx app=app.ts +Deepkit's JSX renderer: +1. **Compiles JSX** to optimized JavaScript functions at runtime +2. **Caches compiled templates** for maximum performance +3. **Provides automatic escaping** to prevent XSS vulnerabilities +4. **Supports full TypeScript** including interfaces, generics, and type checking + +## Basic JSX Views + +### Simple View Component + +```tsx import { App } from '@deepkit/app'; import { HttpRouterRegistry } from "@deepkit/http"; -export function View() { +// A simple view component - just a TypeScript function that returns JSX +export function WelcomeView() { return
-

Hello World

-

My first JSX view

+

Welcome to Deepkit HTTP

+

This is a server-rendered JSX view

+

Generated at: {new Date().toISOString()}

; } const app = new App({}); const router = app.get(HttpRouterRegistry); -router.get('/', () => ); +// Return JSX directly from route handlers +router.get('/', () => ); app.run(); ``` -## Dynamic Views +### Views with Data -JSX views can be dynamic and accept props: +JSX views can accept props just like React components: ```tsx interface UserProfileProps { @@ -38,30 +57,217 @@ interface UserProfileProps { id: number; name: string; email: string; + joinDate: Date; }; + isOwner: boolean; } -function UserProfile({ user }: UserProfileProps) { - return
+function UserProfileView({ user, isOwner }: UserProfileProps) { + return

User Profile

-
- ID: {user.id} +
+

{user.name}

+

Email: {user.email}

+

Member since: {user.joinDate.toLocaleDateString()}

+ + {isOwner && ( +
+ + +
+ )}
-
- Name: {user.name} -
-
- Email: {user.email} +
; +} + +// Use in route handler +router.get('/users/:id', (id: number, currentUserId?: number) => { + const user = getUserById(id); + const isOwner = currentUserId === id; + + return ; +}); +``` + +### Type Safety Benefits + +TypeScript catches errors at compile time: + +```tsx +interface ProductProps { + name: string; + price: number; + inStock: boolean; +} + +function ProductView({ name, price, inStock }: ProductProps) { + return
+

{name}

+

Price: ${price.toFixed(2)}

+ {inStock ? In Stock : Out of Stock} +
; +} + +// TypeScript will catch this error: +// router.get('/product', () => ); +// ❌ Error: Property 'price' is missing + +// Correct usage: +router.get('/product', () => + +); // ✅ Type safe +``` + +## Advanced JSX Patterns + +### Dynamic Content and Conditional Rendering + +JSX supports full JavaScript expressions, making dynamic content natural: + +```tsx +interface DashboardProps { + user: { + name: string; + role: 'admin' | 'user' | 'guest'; + notifications: number; + }; + stats: { + totalUsers: number; + activeUsers: number; + revenue: number; + }; +} + +function DashboardView({ user, stats }: DashboardProps) { + const isAdmin = user.role === 'admin'; + const hasNotifications = user.notifications > 0; + + return
+
+

Welcome back, {user.name}!

+ {hasNotifications && ( +
+ You have {user.notifications} new notification{user.notifications !== 1 ? 's' : ''} +
+ )} +
+ +
+ {isAdmin ? ( + + ) : ( + + )} +
+
; +} + +function AdminPanel({ stats }: { stats: DashboardProps['stats'] }) { + return
+

Admin Dashboard

+
+
+

Total Users

+

{stats.totalUsers.toLocaleString()}

+
+
+

Active Users

+

{stats.activeUsers.toLocaleString()}

+
+
+

Revenue

+

${stats.revenue.toLocaleString()}

+
; } -class UserController { - @http.GET('/users/:id') - getUser(id: number) { - const user = { id, name: `User ${id}`, email: `user${id}@example.com` }; - return ; - } +function UserPanel({ user }: { user: DashboardProps['user'] }) { + return
+

Your Dashboard

+

Role: {user.role}

+ {user.role === 'guest' && ( +
+

Upgrade your account to access more features!

+ +
+ )} +
; +} +``` + +### Lists and Iteration + +Render dynamic lists using JavaScript's array methods: + +```tsx +interface BlogListProps { + posts: Array<{ + id: number; + title: string; + excerpt: string; + author: string; + publishDate: Date; + tags: string[]; + }>; + currentPage: number; + totalPages: number; +} + +function BlogListView({ posts, currentPage, totalPages }: BlogListProps) { + return
+

Latest Blog Posts

+ + {posts.length === 0 ? ( +

No posts available.

+ ) : ( +
+ {posts.map(post => ( +
+

+ {post.title} +

+
+ By {post.author} + {post.publishDate.toLocaleDateString()} +
+

{post.excerpt}

+
+ {post.tags.map(tag => ( + #{tag} + ))} +
+
+ ))} +
+ )} + + +
; +} + +function Pagination({ currentPage, totalPages }: { currentPage: number; totalPages: number }) { + const pages = Array.from({ length: totalPages }, (_, i) => i + 1); + + return ; } ``` From c3725e4803b6372df4de3e7a446927233d1d4712 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Sun, 3 Aug 2025 02:03:34 +0200 Subject: [PATCH 15/15] docs: improve orm --- website/src/pages/documentation/orm/entity.md | 87 ++++- website/src/pages/documentation/orm/events.md | 92 ++++- .../src/pages/documentation/orm/migrations.md | 67 +++- .../pages/documentation/orm/performance.md | 57 ++- website/src/pages/documentation/orm/query.md | 335 +++++++++++++++--- .../src/pages/documentation/orm/relations.md | 138 ++++++-- .../src/pages/documentation/orm/session.md | 110 ++++-- .../src/pages/documentation/orm/testing.md | 28 +- 8 files changed, 774 insertions(+), 140 deletions(-) diff --git a/website/src/pages/documentation/orm/entity.md b/website/src/pages/documentation/orm/entity.md index 66427aa2e..9f697616c 100644 --- a/website/src/pages/documentation/orm/entity.md +++ b/website/src/pages/documentation/orm/entity.md @@ -1,11 +1,30 @@ # Entity -An entity is either a class or an object literal (interface) and always has a primary key. -The entity is decorated with all necessary information using type annotations from `@deepkit/type`. For example, a primary key is defined as well as various fields and their validation constraints. These fields reflect the database structure, usually a table or a collection. +An entity represents a data model that maps to a database table or collection. It defines the structure, constraints, and relationships of your data using TypeScript types and decorators. Entities are the foundation of your application's data layer. -Through special type annotations like `Mapped<'name'>` a field name can also be mapped to another name in the database. +## What is an Entity? -## Class +An entity is a TypeScript class or interface that: +- **Represents a business concept** (User, Product, Order, etc.) +- **Maps to a database table/collection** +- **Has exactly one primary key** +- **Defines field types and constraints** +- **Can have relationships to other entities** + +### Entity Design Principles + +1. **Single Responsibility**: Each entity should represent one clear business concept +2. **Data Integrity**: Use type constraints to enforce business rules +3. **Normalization**: Avoid data duplication through proper relationships +4. **Performance**: Consider indexing for frequently queried fields + +## Entity Definition Methods + +Deepkit ORM supports two approaches for defining entities: classes (recommended) and interfaces. + +## Class-Based Entities (Recommended) + +Classes provide the most flexibility and are the recommended approach for defining entities. They support methods, computed properties, and provide better IDE support. ```typescript import { entity, PrimaryKey, AutoIncrement, Unique, MinLength, MaxLength } from '@deepkit/type'; @@ -21,42 +40,88 @@ class User { public username: string & Unique & MinLength<2> & MaxLength<16>, public email: string & Unique, ) {} + + // Business logic methods + getFullName(): string { + return `${this.firstName || ''} ${this.lastName || ''}`.trim(); + } + + isActive(): boolean { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + return this.created > thirtyDaysAgo; + } } const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User]); await database.migrate(); -await database.persist(new User('Peter')); +const user = new User('peter_doe', 'peter@example.com'); +user.firstName = 'Peter'; +user.lastName = 'Doe'; + +await database.persist(user); const allUsers = await database.query(User).find(); -console.log('all users', allUsers); +console.log('Full name:', allUsers[0].getFullName()); // "Peter Doe" ``` -## Interface +### Benefits of Class-Based Entities + +- **Methods**: Add business logic directly to entities +- **Computed Properties**: Calculate derived values +- **Type Safety**: Full TypeScript support with IntelliSense +- **Inheritance**: Share common functionality between entities +- **Validation**: Custom validation logic in methods + +## Interface-Based Entities + +Interfaces provide a lightweight approach when you only need data structure without methods. They're useful for simple data models or when working with external APIs. ```typescript import { PrimaryKey, AutoIncrement, Unique, MinLength, MaxLength } from '@deepkit/type'; interface User { - id: number & PrimaryKey & AutoIncrement = 0; - created: Date = new Date; + id: number & PrimaryKey & AutoIncrement; + created: Date; firstName?: string; lastName?: string; username: string & Unique & MinLength<2> & MaxLength<16>; + email: string & Unique; } const database = new Database(new SQLiteDatabaseAdapter(':memory:')); -database.register({name: 'user'}); +database.register({ name: 'user' }); await database.migrate(); -const user: User = {id: 0, created: new Date, username: 'Peter'}; +const user: User = { + id: 0, + created: new Date(), + username: 'peter_doe', + email: 'peter@example.com' +}; + await database.persist(user); const allUsers = await database.query().find(); console.log('all users', allUsers); ``` +### When to Use Interfaces + +**Use interfaces when:** +- You need simple data structures without behavior +- Working with external APIs or data sources +- Building lightweight microservices +- You prefer functional programming patterns + +**Use classes when:** +- You need business logic methods +- You want computed properties +- You need complex validation +- You're building rich domain models + ## Primitives Primitive data types like String, Number (bigint), and Boolean are mapped to common database types. Only the TypeScript type is used. diff --git a/website/src/pages/documentation/orm/events.md b/website/src/pages/documentation/orm/events.md index e17502e01..1c50bc736 100644 --- a/website/src/pages/documentation/orm/events.md +++ b/website/src/pages/documentation/orm/events.md @@ -1,48 +1,112 @@ # Events -Events are a way to hook into Deepkit ORM and allow you to write powerful plugins. There are two -categories of events: Query events and Unit-of-Work events. Plugin authors typically use both to -support both ways of manipulating data. +Events provide a powerful way to hook into Deepkit ORM's lifecycle and implement cross-cutting concerns like auditing, caching, validation, and authorization. They allow you to execute custom logic at specific points during database operations without modifying your core business logic. -Events are registered via `Database.listen` un an event token. Short-lived event listeners can also -be registered on sessions. +## Understanding ORM Events + +Events in Deepkit ORM are fired at key moments during database operations, allowing you to: +- **Audit changes**: Track who modified what and when +- **Implement caching**: Cache frequently accessed data +- **Add authorization**: Check permissions before operations +- **Validate data**: Perform complex validation beyond type constraints +- **Transform data**: Modify data before saving or after loading +- **Integrate external systems**: Sync with search engines, message queues, etc. + +## Event Categories + +There are two main categories of events: + +1. **Query Events**: Triggered during direct database queries (`Database.query()`) +2. **Unit-of-Work Events**: Triggered during session operations (`Session.commit()`) + +## Event Registration + +Events can be registered at the database level (global) or session level (scoped): ```typescript import { Query, Database } from '@deepkit/orm'; const database = new Database(...); + +// Global event listener - applies to all operations database.listen(Query.onFetch, async (event) => { + console.log('Data fetched:', event.classSchema.name); }); const session = database.createSession(); -//will only be executed for this particular session +// Session-scoped event listener - only for this session session.eventDispatcher.listen(Query.onFetch, async (event) => { + console.log('Session-specific fetch'); }); ``` +### Event Listener Lifecycle + +```typescript +// Register an event listener +const unsubscribe = database.listen(Query.onFetch, async (event) => { + // Your event handling logic +}); + +// Unregister when no longer needed +unsubscribe(); +``` + ## Query Events -Query events are triggered when a query is executed via `Database.query()` or `Session.query()`. +Query events are triggered when operations are executed via `Database.query()` or `Session.query()`. These events allow you to intercept and modify queries before they're executed, or process results after they're returned. -Each event has its own additional properties such as the type of entity, the query itself and the -database session. You can override the query by setting a new query to `Event.query`. +### Query Event Capabilities + +- **Modify queries**: Add filters, change ordering, or alter the query structure +- **Access metadata**: Get information about the entity type and operation +- **Transform results**: Modify data after it's fetched from the database +- **Implement security**: Add row-level security filters +- **Add logging**: Track query execution for debugging or auditing + +### Basic Query Event Usage ```typescript import { Query, Database } from '@deepkit/orm'; const database = new Database(...); -const unsubscribe = database.listen(Query.onFetch, async event => { - //overwrite the query of the user, so something else is executed. - event.query = event.query.filterField('fieldName', 123); +// Intercept fetch operations +const unsubscribe = database.listen(Query.onFetch, async (event) => { + console.log(`Fetching ${event.classSchema.name} entities`); + + // Add a filter to all queries for this entity + if (event.classSchema.name === 'user') { + event.query = event.query.filter({ active: true }); + } }); -//to delete the hook call unsubscribe +// Clean up when done unsubscribe(); ``` -"Query" has several event tokens: +### Practical Example: Soft Delete Implementation + +```typescript +// Automatically filter out deleted records +database.listen(Query.onFetch, async (event) => { + // Add deleted filter to all entities that have a 'deletedAt' field + if (event.classSchema.hasProperty('deletedAt')) { + event.query = event.query.filter({ deletedAt: null }); + } +}); + +// Automatically set deletedAt instead of actually deleting +database.listen(Query.onDeletePre, async (event) => { + if (event.classSchema.hasProperty('deletedAt')) { + // Convert delete to update + event.query = event.query.patchMany({ deletedAt: new Date() }); + } +}); +``` + +### Available Query Events | Event-Token | Description | |--------------------|-------------------------------------------------------------| diff --git a/website/src/pages/documentation/orm/migrations.md b/website/src/pages/documentation/orm/migrations.md index d4adc1974..78ee5be7d 100644 --- a/website/src/pages/documentation/orm/migrations.md +++ b/website/src/pages/documentation/orm/migrations.md @@ -1,19 +1,68 @@ # Migrations -Migrations are a way to make database schema changes in a structured and organized manner. They are stored as TypeScript files in a directory and can be executed using the command-line tool. +Migrations provide a version-controlled way to evolve your database schema over time. They ensure that database changes are applied consistently across different environments (development, staging, production) and can be safely rolled back if needed. -Deepkit ORM migrations are enabled by default when Deepkit Framework is used. +## Why Use Migrations? -## Commands +Migrations solve several critical problems in database management: -- `migration:create` - Generates a new migration file based on a database diff -- `migration:pending` - Shows pending migration files -- `migration:up` - Executes pending migration files. -- `migration:down` - Executes down migration, reverting old migration files +1. **Version Control**: Track database schema changes alongside code changes +2. **Team Collaboration**: Ensure all developers have the same database structure +3. **Deployment Safety**: Apply schema changes reliably in production +4. **Rollback Capability**: Revert problematic changes when needed +5. **Environment Consistency**: Keep development, staging, and production in sync -These commands are available either in application when you import the `FrameworkModule` or via the `deepkit-sql` command-line tool from `@deepkit/sql`. +## How Migrations Work -The [migration integration of FrameworkModule](../framework/database.md#migration) automatically reads your Databases (you have to define them as provider), while with `deepkit-sql` you have to specify the TypeScript file that exports the database. THe latter is useful if you use Deepkit ORM standalone without Deepkit Framework. +Deepkit ORM automatically generates migrations by comparing your current entity definitions with the existing database schema. This diff-based approach means you focus on defining your entities, and the ORM handles the database changes. + +### Migration Lifecycle + +1. **Modify Entities**: Change your TypeScript entity definitions +2. **Generate Migration**: Run `migration:create` to generate a migration file +3. **Review & Customize**: Examine and optionally modify the generated migration +4. **Apply Migration**: Run `migration:up` to execute pending migrations +5. **Deploy**: Include migration files in your deployment process + +Deepkit ORM migrations are enabled by default when using Deepkit Framework. + +## Migration Commands + +Deepkit ORM provides several commands for managing migrations: + +| Command | Purpose | When to Use | +|---------|---------|-------------| +| `migration:create` | Generates a new migration file based on entity changes | After modifying entity definitions | +| `migration:pending` | Shows migrations that haven't been applied yet | Before deploying or to check status | +| `migration:up` | Executes pending migrations | During deployment or development setup | +| `migration:down` | Reverts the last migration | When you need to rollback changes | + +### Command Availability + +These commands are available in two ways: + +1. **With Deepkit Framework**: Commands are automatically available when you import `FrameworkModule` +2. **Standalone**: Use the `deepkit-sql` CLI tool from `@deepkit/sql` package + +### Framework vs Standalone + +**Deepkit Framework Integration**: +- Automatically discovers database providers +- Integrates with application configuration +- Suitable for full Deepkit applications + +**Standalone CLI**: +- Requires explicit database file specification +- Useful for ORM-only projects +- More control over migration process + +```bash +# Framework approach (automatic discovery) +npm run migration:create + +# Standalone approach (explicit database file) +./node_modules/.bin/deepkit-sql migration:create --path database.ts --migrationDir src/migrations +``` ## Creating a migration diff --git a/website/src/pages/documentation/orm/performance.md b/website/src/pages/documentation/orm/performance.md index 5d38d3263..5688c9e29 100644 --- a/website/src/pages/documentation/orm/performance.md +++ b/website/src/pages/documentation/orm/performance.md @@ -1,6 +1,25 @@ # Performance Best Practices -Optimizing database performance is crucial for scalable applications. Deepkit ORM provides several features and patterns to help you build high-performance database operations. +Database performance is critical for application scalability and user experience. Deepkit ORM provides powerful features for optimization, but understanding when and how to use them is key to building high-performance applications. + +## Performance Fundamentals + +Before diving into specific techniques, understand these core principles: + +1. **Minimize Database Round Trips**: Batch operations when possible +2. **Use Appropriate Indexes**: Index frequently queried and sorted fields +3. **Select Only Needed Data**: Avoid loading unnecessary fields or relationships +4. **Leverage Database Features**: Use aggregation, filtering, and sorting at the database level +5. **Monitor Query Patterns**: Identify and optimize slow queries + +## Understanding the Performance Impact + +Different ORM patterns have varying performance characteristics: + +- **Sessions**: Higher memory usage but better for complex operations +- **Direct Queries**: Lower overhead for simple operations +- **Joins**: More efficient than N+1 queries but can be complex +- **Aggregation**: Much faster than application-level calculations ## Session vs Database Queries @@ -66,10 +85,38 @@ await session.commit(); // Automatically generates UPDATE statement ``` ### Identity Map Best Practices: -- Use sessions for related operations to benefit from identity map -- Be aware that sessions hold references to entities (memory usage) -- Create new sessions for different logical units of work -- Don't keep sessions alive too long in long-running processes + +**Benefits:** +- Eliminates duplicate database queries for the same entity +- Ensures object identity consistency +- Enables automatic change detection +- Reduces memory allocation for duplicate objects + +**Considerations:** +- Sessions hold references to all loaded entities (memory usage) +- Identity map is per-session, not global +- Long-lived sessions can accumulate many entities + +**Best Practices:** +```typescript +// Good: Short-lived session for related operations +async function updateUserProfile(userId: number, updates: any) { + const session = database.createSession(); + + const user = await session.query(User).filter({ id: userId }).findOne(); + const profile = await session.query(Profile).filter({ userId }).findOne(); + + // Both entities benefit from identity map + Object.assign(user, updates); + profile.lastUpdated = new Date(); + + await session.commit(); + // Session is garbage collected after function ends +} + +// Avoid: Long-lived sessions that accumulate entities +const globalSession = database.createSession(); // Don't do this +``` ## Batch Operations diff --git a/website/src/pages/documentation/orm/query.md b/website/src/pages/documentation/orm/query.md index 6cf488384..f1e388ea0 100644 --- a/website/src/pages/documentation/orm/query.md +++ b/website/src/pages/documentation/orm/query.md @@ -1,9 +1,11 @@ # Query -A query is an object that describes how to retrieve or modify data from the database. It has several methods to describe the query and termination methods that execute them. The database adapter can extend the query API in many ways to support database specific features. +A query is an object that describes how to retrieve or modify data from the database. It provides a fluent API that allows you to build complex database operations in a type-safe manner. The query builder pattern ensures that your queries are constructed correctly at compile time, reducing runtime errors. -You can create a query using `Database.query(T)` or `Session.query(T)`. We recommend Sessions as it improves performance and provides automatic change detection. +## Query Creation and Execution + +You can create a query using `Database.query(T)` or `Session.query(T)`. We strongly recommend using Sessions as they provide significant performance benefits through identity mapping and automatic change detection. ```typescript @entity.name('user') @@ -19,111 +21,340 @@ class User { const database = new Database(...); -//[ { username: 'User1' }, { username: 'User2' }, { username: 'User3' } ] +// Direct database query (basic approach) const users = await database.query(User).select('username').find(); +// Result: [{ username: 'User1' }, { username: 'User2' }, { username: 'User3' }] -// Using session (recommended for better performance) +// Using session (recommended for better performance and change tracking) const session = database.createSession(); const users = await session.query(User).select('username').find(); ``` +### Why Use Sessions? + +Sessions provide several advantages: +- **Identity Map**: Ensures the same entity instance is returned for the same database record +- **Change Detection**: Automatically tracks modifications to loaded entities +- **Performance**: Reduces database round trips by caching entities +- **Consistency**: Maintains object identity across multiple queries + +## Query Building Pattern + +Queries in Deepkit ORM follow a builder pattern where you chain methods to construct your query, then call a termination method to execute it: + +```typescript +const result = await database.query(User) + .filter({ visits: { $gt: 10 } }) // Add conditions + .orderBy('created', 'desc') // Sort results + .limit(20) // Limit results + .find(); // Execute query (termination method) +``` + +**Termination methods** execute the query and return results: +- `find()` - Returns array of entities +- `findOne()` - Returns single entity (throws if not found) +- `findOneOrUndefined()` - Returns single entity or undefined +- `count()` - Returns number of matching records +- `has()` - Returns boolean indicating if any records exist + ## Filter -A filter can be applied to limit the result set. +Filters allow you to specify conditions that limit which records are returned from the database. Deepkit ORM provides a comprehensive filtering system that supports simple equality checks, range queries, pattern matching, and complex logical operations. + +### Basic Equality Filters + +The simplest filters check for exact matches: ```typescript -//simple filters -const users = await database.query(User).filter({name: 'User1'}).find(); +// Single condition - finds users with exact username +const users = await database.query(User).filter({ username: 'User1' }).find(); -//multiple filters, all AND -const users = await database.query(User).filter({name: 'User1', id: 2}).find(); +// Multiple conditions (implicit AND) - both conditions must be true +const users = await database.query(User).filter({ username: 'User1', visits: 5 }).find(); +// SQL equivalent: WHERE username = 'User1' AND visits = 5 +``` -//range filter: $gt, $lt, $gte, $lte (greater than, lower than, ...) -//equivalent to WHERE created < NOW() -const users = await database.query(User).filter({created: {$lt: new Date}}).find(); -//equivalent to WHERE id > 500 -const users = await database.query(User).filter({id: {$gt: 500}}).find(); -//equivalent to WHERE id >= 500 -const users = await database.query(User).filter({id: {$gte: 500}}).find(); +### Range Filters -//set filter: $in, $nin (in, not in) -//equivalent to WHERE id IN (1, 2, 3) -const users = await database.query(User).filter({id: {$in: [1, 2, 3]}}).find(); +Use comparison operators to filter by ranges: -//regex filter -const users = await database.query(User).filter({username: {$regex: /User[0-9]+/}}).find(); +```typescript +// Greater than operators +const activeUsers = await database.query(User).filter({ visits: { $gt: 10 } }).find(); +const recentUsers = await database.query(User).filter({ created: { $gte: new Date('2023-01-01') } }).find(); -//grouping: $and, $nor, $or -//equivalent to WHERE (username = 'User1') OR (username = 'User2') -const users = await database.query(User).filter({ - $or: [{username: 'User1'}, {username: 'User2'}] +// Less than operators +const newUsers = await database.query(User).filter({ visits: { $lt: 5 } }).find(); +const oldUsers = await database.query(User).filter({ created: { $lte: new Date('2022-12-31') } }).find(); + +// Combining range conditions +const moderateUsers = await database.query(User).filter({ + visits: { $gte: 5, $lt: 20 } // Between 5 and 19 visits }).find(); +``` +**Range Operators:** +- `$gt` - Greater than +- `$gte` - Greater than or equal +- `$lt` - Less than +- `$lte` - Less than or equal -//nested grouping -//equivalent to WHERE username = 'User1' OR (username = 'User2' and id > 0) -const users = await database.query(User).filter({ - $or: [{username: 'User1'}, {username: 'User2', id: {$gt: 0}}] +### Set-based Filters + +Filter by checking if values are in a specific set: + +```typescript +// Include specific values +const specificUsers = await database.query(User).filter({ + id: { $in: [1, 2, 3, 5, 8] } }).find(); +// Exclude specific values +const excludedUsers = await database.query(User).filter({ + username: { $nin: ['admin', 'test', 'guest'] } +}).find(); +``` -//nested grouping -//equivalent to WHERE username = 'User1' AND (created < NOW() OR id > 0) -const users = await database.query(User).filter({ - $and: [{username: 'User1'}, {$or: [{created: {$lt: new Date}, id: {$gt: 0}}]}] +### Pattern Matching + +Use regular expressions for flexible text matching: + +```typescript +// Find usernames matching a pattern +const numberedUsers = await database.query(User).filter({ + username: { $regex: /^User\d+$/ } // Matches User1, User2, etc. +}).find(); + +// Case-insensitive search +const emailPattern = await database.query(User).filter({ + email: { $regex: /gmail\.com$/i } }).find(); ``` -### Equal +### Logical Grouping + +Combine multiple conditions with logical operators: + +```typescript +// OR condition - either condition can be true +const users = await database.query(User).filter({ + $or: [ + { username: 'User1' }, + { username: 'User2' } + ] +}).find(); + +// Complex nested conditions +const complexFilter = await database.query(User).filter({ + $and: [ + { visits: { $gt: 0 } }, // Must have visits + { + $or: [ + { username: { $regex: /^Admin/ } }, // Admin users + { created: { $gte: new Date('2023-01-01') } } // Or recent users + ] + } + ] +}).find(); +``` -### Greater / Smaller +**Logical Operators:** +- `$and` - All conditions must be true (default behavior) +- `$or` - At least one condition must be true +- `$nor` - None of the conditions can be true -### RegExp +### Filter Chaining -### Grouping AND/OR +You can chain multiple filter calls, which creates an implicit AND relationship: -### In +```typescript +const users = await database.query(User) + .filter({ visits: { $gt: 5 } }) // First condition + .filter({ created: { $gte: new Date('2023-01-01') } }) // AND second condition + .find(); +``` ## Select -To narrow down the fields to be received from the database, `select('field1')` can be used. +The `select()` method allows you to specify which fields should be retrieved from the database, reducing data transfer and improving query performance. This is particularly useful when you only need specific fields from large entities. + +### Basic Field Selection ```typescript -const user = await database.query(User).select('username').findOne(); -const user = await database.query(User).select('id', 'username').findOne(); +// Select single field +const usernames = await database.query(User).select('username').find(); +// Result: [{ username: 'User1' }, { username: 'User2' }] + +// Select multiple fields +const basicInfo = await database.query(User).select('id', 'username', 'created').find(); +// Result: [{ id: 1, username: 'User1', created: Date }, ...] ``` -It is important to note that as soon as the fields are narrowed down using `select`, the results are no longer instances of the entity, but only object literals. +### Important: Type Changes with Select + +When using `select()`, the returned objects are **plain objects**, not entity instances: +```typescript +// Without select - returns entity instances +const user = await database.query(User).findOne(); +console.log(user instanceof User); // true +user.someMethod(); // Works if User has methods + +// With select - returns plain objects +const userData = await database.query(User).select('username').findOne(); +console.log(userData instanceof User); // false +// userData.someMethod(); // Error! Not an entity instance ``` -const user = await database.query(User).select('username').findOne(); -user instanceof User; //false + +### When to Use Select + +**Use `select()` when:** +- You need only specific fields for display or processing +- Working with large entities with many fields +- Building APIs that return specific data shapes +- Performance is critical and you want to minimize data transfer + +**Avoid `select()` when:** +- You need the full entity for business logic +- You plan to modify and persist the entity +- You need entity methods or instanceof checks + +### Performance Benefits + +```typescript +// Inefficient - loads all fields including large text fields +const users = await database.query(User).find(); + +// Efficient - loads only needed fields +const userList = await database.query(User) + .select('id', 'username', 'email') + .find(); ``` ## Order -With `orderBy(field, order)` the order of the entries can be changed. -Several times `orderBy` can be executed to refine the order more and more. +The `orderBy()` method controls the sorting of query results. You can sort by one or multiple fields, and combine different sort directions to create complex ordering rules. + +### Basic Sorting ```typescript -const users = await session.query(User).orderBy('created', 'desc').find(); -const users = await session.query(User).orderBy('created', 'asc').find(); +// Sort by creation date (newest first) +const recentUsers = await database.query(User).orderBy('created', 'desc').find(); + +// Sort by username alphabetically +const alphabeticalUsers = await database.query(User).orderBy('username', 'asc').find(); +``` + +### Multiple Sort Criteria + +Chain multiple `orderBy()` calls to create complex sorting rules. The first `orderBy()` is the primary sort, subsequent calls are used for tie-breaking: + +```typescript +// Primary sort by visits (descending), then by username (ascending) for ties +const users = await database.query(User) + .orderBy('visits', 'desc') // Primary: most active users first + .orderBy('username', 'asc') // Secondary: alphabetical for same visit count + .find(); + +// Sort by creation date, then by ID for consistent ordering +const consistentOrder = await database.query(User) + .orderBy('created', 'desc') + .orderBy('id', 'asc') + .find(); +``` + +### Sort Directions + +- `'asc'` - Ascending order (A-Z, 0-9, oldest to newest) +- `'desc'` - Descending order (Z-A, 9-0, newest to oldest) + +### Performance Considerations + +For optimal performance, ensure you have database indexes on fields used in `orderBy()`: + +```typescript +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + created: Date & Index = new Date(); // Index for sorting performance + username: string & Index = ''; // Index for sorting performance + visits: number = 0; +} ``` ## Pagination -The `itemsPerPage()` and `page()` methods can be used to paginate the results. Page starts at 1. +Pagination is essential for handling large datasets efficiently. Deepkit ORM provides two approaches: high-level pagination methods and low-level limit/skip methods. + +### High-Level Pagination + +Use `itemsPerPage()` and `page()` for user-friendly pagination: + +```typescript +// Get first page (20 users per page) +const firstPage = await database.query(User) + .itemsPerPage(20) + .page(1) // Pages start at 1 + .find(); + +// Get third page +const thirdPage = await database.query(User) + .itemsPerPage(20) + .page(3) + .find(); +``` + +### Low-Level Pagination + +Use `limit()` and `skip()` for more control: ```typescript -const users = await session.query(User).itemsPerPage(50).page(1).find(); +// Skip first 10 users, get next 5 +const users = await database.query(User) + .skip(10) + .limit(5) + .find(); + +// Equivalent to page 3 with 20 items per page +const page3 = await database.query(User) + .skip(40) // (page - 1) * itemsPerPage = (3 - 1) * 20 + .limit(20) + .find(); ``` -With the alternative methods `limit` and `skip` you can paginate manually. +### Complete Pagination Example ```typescript -const users = await session.query(User).limit(5).skip(10).find(); +async function getUsersPage(page: number, pageSize: number = 20) { + const users = await database.query(User) + .orderBy('created', 'desc') // Consistent ordering important for pagination + .itemsPerPage(pageSize) + .page(page) + .find(); + + const totalUsers = await database.query(User).count(); + const totalPages = Math.ceil(totalUsers / pageSize); + + return { + users, + pagination: { + currentPage: page, + pageSize, + totalUsers, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1 + } + }; +} ``` +### Performance Tips + +- Always use `orderBy()` with pagination to ensure consistent results +- Consider using cursor-based pagination for very large datasets +- Use `count()` sparingly as it can be expensive on large tables + [#database-join] ## Join diff --git a/website/src/pages/documentation/orm/relations.md b/website/src/pages/documentation/orm/relations.md index 399fd39ce..08ed3ecfa 100644 --- a/website/src/pages/documentation/orm/relations.md +++ b/website/src/pages/documentation/orm/relations.md @@ -1,20 +1,47 @@ # Relations -Relationships allow you to connect two entities in a certain way. This is usually done in databases using the concept of foreign keys. Deepkit ORM supports relations for all official database adapters. +Relations define how entities are connected to each other, representing the relationships between different types of data in your application. They are fundamental to relational database design and allow you to model complex data structures efficiently. -A relation is annotated with the `Reference` decorator. Usually a relation also has a reverse relation, which is annotated with the `BackReference` type, but is only needed if the reverse relation is to be used in a database query. Back references are only virtual. +## Understanding Relations -## One To Many +In database terms, relations are implemented using foreign keys - fields that reference the primary key of another table. Deepkit ORM abstracts this complexity while providing type-safe access to related data. -The entity that stores a reference is usually referred to as the `owning side` or the one that `owns` the reference. The following code shows two entities with a one-to-many relationship between `User` and `Post`. This means that one `User` can have multiple `Post`. The `post` entity has the `post->user` relationship. In the database itself there is now a field `Post. "author"` that contains the primary key of `User`. +### Key Concepts + +**Reference**: The actual foreign key field that stores the connection to another entity. Annotated with `Reference`. + +**BackReference**: A virtual property that provides reverse navigation from the referenced entity back to the referencing entities. Annotated with `BackReference`. + +**Owning Side**: The entity that contains the foreign key field (the `Reference`). This side controls the relationship. + +**Inverse Side**: The entity that is referenced, typically containing a `BackReference` for navigation. + +### Why Use Relations? + +- **Data Integrity**: Ensure referential integrity through foreign key constraints +- **Normalization**: Avoid data duplication by storing related data in separate entities +- **Query Efficiency**: Join related data efficiently at the database level +- **Type Safety**: Get compile-time checking for relationship navigation + +## One-to-Many Relations + +A one-to-many relationship means that one entity can be associated with multiple instances of another entity, but each instance of the second entity belongs to only one instance of the first entity. + +### Common Examples +- **User → Posts**: One user can write many posts, but each post has one author +- **Category → Products**: One category contains many products, but each product belongs to one category +- **Department → Employees**: One department has many employees, but each employee works in one department + +### Implementation + +The entity that stores the foreign key is called the **owning side**: ```typescript import { SQLiteDatabaseAdapter } from '@deepkit/sqlite'; -import { entity, PrimaryKey, AutoIncrement, - Reference } from '@deepkit/type'; +import { entity, PrimaryKey, AutoIncrement, Reference } from '@deepkit/type'; import { Database } from '@deepkit/orm'; -@entity.collection('users') +@entity.name('user') class User { id: number & PrimaryKey & AutoIncrement = 0; created: Date = new Date; @@ -23,58 +50,119 @@ class User { } } -@entity.collection('posts') +@entity.name('post') class Post { id: number & PrimaryKey & AutoIncrement = 0; created: Date = new Date; constructor( - public author: User & Reference, - public title: string + public author: User & Reference, // Foreign key to User + public title: string, + public content: string = '' ) { } } +``` -const database = new Database( - new SQLiteDatabaseAdapter(':memory:'), - [User, Post] -); +### Database Structure + +This creates the following database structure: +- `user` table: `id`, `username`, `created` +- `post` table: `id`, `author` (foreign key to user.id), `title`, `content`, `created` + +### Creating Related Data + +```typescript +const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User, Post]); await database.migrate(); -const user1 = new User('User1'); -const post1 = new Post(user1, 'My first blog post'); -const post2 = new Post(user1, 'My second blog post'); +// Create user first +const user = new User('john_doe'); +await database.persist(user); + +// Create posts referencing the user +const post1 = new Post(user, 'Introduction to TypeScript'); +const post2 = new Post(user, 'Advanced ORM Patterns'); -await database.persist(user1, post1, post2); +await database.persist(post1, post2); ``` -References are not selected in queries by default. See [Database Joins](./query.md#join) for details. +### Important Notes -## Many To One +- **References are not loaded by default**: When you query for posts, the `author` field contains only the foreign key value, not the full User object +- **Use joins to load references**: See [Database Joins](./query.md#join) for details on loading related data +- **Foreign key constraints**: The database ensures that the referenced user exists -A reference usually has a reverse reference called many-to-one. It is only a virtual reference, since it is not reflected in the database itself. A back reference is annotated `BackReference` and is mainly used for reflection and query joins. If you add a `BackReference` from `User` to `Post`, you can join `Post` directly from `User` queries. +## Many-to-One Relations (Back References) + +A back reference provides navigation from the "one" side back to the "many" side of a relationship. It's a virtual property that doesn't exist in the database but allows you to query related entities efficiently. + +### Understanding Back References + +Back references are **virtual** - they don't create database fields but enable: +- **Reverse navigation**: Navigate from User to their Posts +- **Query joins**: Include related data in queries +- **Type safety**: Compile-time checking for relationship navigation + +### Adding Back References ```typescript -@entity.name('user').collection('users') +@entity.name('user') class User { id: number & PrimaryKey & AutoIncrement = 0; created: Date = new Date; + // Virtual property - not stored in database posts?: Post[] & BackReference; constructor(public username: string) { } } + +@entity.name('post') +class Post { + id: number & PrimaryKey & AutoIncrement = 0; + created: Date = new Date; + + constructor( + public author: User & Reference, // Actual foreign key + public title: string + ) { + } +} ``` +### Using Back References in Queries + ```typescript -//[ { username: 'User1', posts: [ [Post], [Post] ] } ] -const users = await database.query(User) +// Load users with their posts +const usersWithPosts = await database.query(User) .select('username', 'posts') - .joinWith('posts') + .joinWith('posts') // Load the related posts + .find(); + +// Result: [{ username: 'john_doe', posts: [Post, Post, ...] }] + +// Filter users by their posts +const activeAuthors = await database.query(User) + .useJoinWith('posts') + .filter({ created: { $gte: new Date('2023-01-01') } }) + .end() .find(); ``` +### When to Use Back References + +**Use back references when:** +- You need to navigate from the "one" side to the "many" side +- You want to filter or sort by related entity properties +- You need to load related data efficiently + +**Skip back references when:** +- You only navigate from "many" to "one" (use Reference only) +- The relationship is rarely used in queries +- You want to keep the entity interface minimal + ## Many To Many A many-to-many relationship allows you to associate many records with many others. For example, it can be used for users in groups. A user can be in none, one or many groups. Consequently, a group can contain 0, one or many users. diff --git a/website/src/pages/documentation/orm/session.md b/website/src/pages/documentation/orm/session.md index ca241e0da..e994c73ce 100644 --- a/website/src/pages/documentation/orm/session.md +++ b/website/src/pages/documentation/orm/session.md @@ -1,49 +1,93 @@ # Session / Unit Of Work -A session is something like a unit of work. It keeps track of everything you do and automatically records the changes whenever `commit()` is called. It is the preferred way to execute changes in the database because it bundles statements in a way that makes it very fast. A session is very lightweight and can easily be created in a request-response lifecycle, for example. +A session implements the Unit of Work pattern, which tracks all changes to entities and executes them as a batch when `commit()` is called. This approach provides significant performance benefits and ensures data consistency. + +## Why Use Sessions? + +Sessions are the **recommended way** to work with Deepkit ORM because they provide: + +1. **Performance**: Batch database operations instead of individual queries +2. **Change Detection**: Automatically track modifications to loaded entities +3. **Identity Map**: Ensure the same entity instance for the same database record +4. **Transaction Management**: Group related changes into atomic operations +5. **Memory Efficiency**: Optimize database round trips + +## Basic Session Usage ```typescript import { SQLiteDatabaseAdapter } from '@deepkit/sqlite'; import { entity, PrimaryKey, AutoIncrement } from '@deepkit/type'; import { Database } from '@deepkit/orm'; -async function main() { - - @entity.name('user') - class User { - id: number & PrimaryKey & AutoIncrement = 0; - created: Date = new Date; +@entity.name('user') +class User { + id: number & PrimaryKey & AutoIncrement = 0; + created: Date = new Date; - constructor(public name: string) { - } + constructor(public name: string) { } +} - const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User]); - await database.migrate(); +const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User]); +await database.migrate(); - const session = database.createSession(); - session.add(new User('User1'), new User('User2'), new User('User3')); +// Create a session +const session = database.createSession(); - await session.commit(); +// Add new entities to the session +session.add(new User('Alice'), new User('Bob'), new User('Charlie')); - const users = await session.query(User).find(); - console.log(users); -} +// Execute all changes in a single batch +await session.commit(); -main(); +// Query through the session for identity map benefits +const users = await session.query(User).find(); +console.log(users); ``` -Add new instance to the session with `session.add(T)` or remove existing instances with `session.remove(T)`. Once you are done with the Session object, simply dereference it everywhere so that the garbage collector can remove it. +## Session Lifecycle + +### 1. Creation +Sessions are lightweight and designed for short-lived operations (e.g., request-response cycle): + +```typescript +const session = database.createSession(); +``` + +### 2. Entity Management +Add new entities or remove existing ones: + +```typescript +// Add new entities +session.add(new User('John')); + +// Remove entities (will be deleted on commit) +const userToDelete = await session.query(User).filter({ name: 'John' }).findOne(); +session.remove(userToDelete); +``` -Changes are automatically detected for entity instances fetched via the Session object. +### 3. Automatic Change Detection +Entities loaded through the session are automatically tracked: ```typescript const users = await session.query(User).find(); for (const user of users) { - user.name += ' changed'; + user.name += ' (updated)'; // Changes are automatically detected } -await session.commit();//saves all users +await session.commit(); // Saves all modified users +``` + +### 4. Cleanup +Sessions are garbage collected automatically when dereferenced: + +```typescript +// Session will be cleaned up when it goes out of scope +function handleRequest() { + const session = database.createSession(); + // ... use session + // No explicit cleanup needed +} ``` ## Identity Map @@ -78,6 +122,28 @@ await session.commit(); // Saves the changes - **Scope**: Identity map is per-session, not global - **Lifecycle**: Entities remain in memory until session is garbage collected +### Identity Map Best Practices + +```typescript +// Good: Use sessions for related operations +async function updateUserProfile(userId: number, updates: Partial) { + const session = database.createSession(); + + const user = await session.query(User).filter({ id: userId }).findOne(); + const profile = await session.query(Profile).filter({ userId }).findOne(); + + // Both entities are in the same identity map + Object.assign(user, updates); + profile.lastUpdated = new Date(); + + await session.commit(); // Saves both entities efficiently +} + +// Avoid: Long-lived sessions with many entities +// This can cause memory issues +const globalSession = database.createSession(); // Don't do this +``` + ## Change Detection Sessions automatically track changes to entities loaded through the session: diff --git a/website/src/pages/documentation/orm/testing.md b/website/src/pages/documentation/orm/testing.md index 8bda73f14..028b2a2f1 100644 --- a/website/src/pages/documentation/orm/testing.md +++ b/website/src/pages/documentation/orm/testing.md @@ -1,12 +1,36 @@ # Testing with Deepkit ORM -Testing database-driven applications requires careful consideration of data isolation, performance, and test reliability. Deepkit ORM provides several tools and patterns to make testing easier and more effective. +Testing database-driven applications presents unique challenges: ensuring data isolation between tests, managing test performance, and maintaining test reliability across different environments. Deepkit ORM provides several strategies and tools to address these challenges effectively. + +## Testing Challenges and Solutions + +### Common Testing Challenges + +1. **Data Isolation**: Tests should not interfere with each other +2. **Performance**: Tests should run quickly to maintain developer productivity +3. **Reliability**: Tests should produce consistent results across environments +4. **Complexity**: Database tests can be complex to set up and maintain + +### Deepkit ORM Solutions + +- **Multiple Database Adapters**: Choose the right adapter for different testing scenarios +- **In-Memory Databases**: Fast, isolated test environments +- **Session Management**: Clean, predictable state management +- **Migration Support**: Consistent schema setup across test runs ## Database Adapters for Testing ### SQLite Database Adapter (Recommended) -For most testing scenarios, we recommend using `SQLiteDatabaseAdapter` with in-memory databases. This provides the best balance of performance, SQL compatibility, and feature support: +SQLite with in-memory databases is the recommended approach for most testing scenarios. It provides an excellent balance of performance, SQL feature compatibility, and isolation. + +#### Why SQLite for Testing? + +- **Performance**: In-memory databases are extremely fast +- **Isolation**: Each test gets a completely fresh database +- **SQL Compatibility**: Supports most SQL features including joins, aggregations, and transactions +- **No Setup**: No external database server required +- **Deterministic**: Consistent behavior across different environments ```typescript import { SQLiteDatabaseAdapter } from '@deepkit/sqlite';