UNPKG

@martinmilo/verve

Version:

TypeScript domain modeling library with field-level authorization, business rule validation, and context-aware access control

1,290 lines (993 loc) 39.2 kB
# Verve A TypeScript domain modeling library that helps you build secure business layers with **field-level authorization**, **business rule validation**, and **context-aware access control**. ## Why Verve? Building robust domain models means more than just defining fields. You need to: - **Enforce business rules** at the field level - **Control who can read/write** specific fields based on context - **Authorize method calls** based on user roles and permissions - **Validate business invariants** automatically - **Prevent unauthorized access** to sensitive data Verve makes this **declarative** and **type-safe**. ## Installation ```bash npm install verve ``` ## Use Cases - **🏦 Financial Applications** - Secure account data, transaction authorization - **🏥 Healthcare Systems** - HIPAA-compliant patient data access - **👥 Multi-tenant SaaS** - User isolation and role-based permissions - **📊 Admin Dashboards** - Role-based field visibility and editing - **🔐 Enterprise Apps** - Complex authorization workflows ## Philosophy of Verve Verve is built on four core principles that guide every design decision: ### 🚨 **No Silent Errors** We believe errors should be **loud and immediate**. When something goes wrong, Verve throws exceptions as soon as possible rather than allowing invalid states to propagate through your application or worse, corrupt your data. This means: - **Field access validation** - Accessing uninitialized fields throws immediately - **Authorization failures** - Permission violations throw clear, actionable errors - **Business rule violations** - Validation failures are caught at the point of assignment or can be lazily evaluated at any point - **Type safety** - Invalid operations are prevented at compile-time when possible ```typescript const user = User.from({ name: 'John' }) // No email provided user.name // ✅ 'John' - field is initialized user.email // ❌ Throws - field is uninitialized ``` ### 🎯 **Centralized Business Rules** Rather than spreading business logic throughout your application layers, Verve keeps domain rules **transparent and in one place** - your model definitions. This means: - **Field-level validation** - Business rule(s) applicable on single field - **Authorization logic** - Access control is declared alongside field definitions - **Business invariants** - Cross-field validation ensures domain consistency - **Single source of truth** - No hunting through controllers, services, and middleware ```typescript @model({ // Business rule: Salary constraints based on level salary: number().validate([ (value, model) => { if (model.level === 'junior') return value <= 80_000 if (model.level === 'senior') return value >= 100_000 return value > 0 } ]) // Authorization: Only HR can read salary .readable((context) => context.auth.department === 'HR'), level: text() }) class Employee extends Model.Typed<'Employee'>() {} ``` ### 🔐 **Privacy is King** Data privacy and security are not afterthoughts - they're **fundamental to how Verve works**. Every field access is checked and validated: - **Field-level authorization** - Control exactly who can read/write each field - **Context-aware permissions** - Access rules adapt based on user roles and relationships - **Secure by default** - Sensitive fields can be hidden unless explicitly authorized - **Method authorization** - Business operations require proper permissions ```typescript @model({ // Public information name: text(), // Sensitive data - never readable by default ssn: text().readable(false), // Context-sensitive access salary: number().readable((context, employee) => context.auth.id === employee.id || context.auth.role === Role.HR ) }) class Employee extends Model.Typed<'Employee'>() {} ``` ### **Safe Partial Models** Verve provides **crystal-clear semantics** for field states, eliminating confusion between `null` and `undefined`: - **`undefined`** - Field is uninitialized and will throw on access - **`null`** - Field is intentionally empty (nullable fields only) This enables safe partial model hydration where you can work with incomplete data while maintaining strict safety guarantees: ```typescript // Partial hydration from API - only some fields provided const user = User.from({ id: '123', name: 'John' }) user.name // ✅ 'John' - field is initialized user.email // ❌ Throws - field is uninitialized user.isActive // ❌ Throws - field is uninitialized // Check field state safely via model method user.hasPresent('email') // false - field 'email' is uninitialized user.hasPresent('name') // true - field 'name' has value // Validate model fields against their validators safely const errors = user.validate() // VerveErrorList if (errors.isEmpty()) { // You're good to go } // Nullable vs uninitialized user.set({ bio: null }) // ✅ Explicitly set to null (if nullable) user.set({ email: null }) // ❌ Throws since field is not nullable (IDE should also complain) ``` These principles ensure your domain models are **secure, predictable, and maintainable** while providing excellent developer experience through clear error messages and type safety. ## Practical Example ```typescript import { Model, model, can, text, id, number, bool, option } from 'verve' enum Role { USER = 'user', ADMIN = 'admin' } @model({ id: id(), email: text().validate.only([(value) => value.includes('@')]), // Only readable by the user themselves or admins ssn: text().readable((context, user) => context.auth.id === user.id || context.auth.role === Role.ADMIN ), // Only writable by admins role: option(Role).writable((context) => context.auth.role === Role.ADMIN ), // Business rule: age must be over 18 age: number().validate.only([(value) => value >= 18]), // Never readable (passwords should never be exposed to anyone) password: text().readable(false), isActive: bool().default(true) }) class User extends Model.Typed<'User'>() { // Only the user themselves can generate their credit report @can((context, user) => context.auth.id === user.id) generateCreditReport() { // Logic to generate credit report… } // Users can close their own account, admins can close any account @can((context, user) => context.auth.id === user.id || context.auth.role === Role.ADMIN ) closeAccount(reason: string) { // Logic to archive and close account, notify user… } } ``` ## Quick Start Let's build your first Verve model step by step: ### 1. Define a Basic Model ```typescript import { Model, model, text, id, number, bool, date } from 'verve' @model({ id: id(), name: text(), email: text(), age: number().nullable, isActive: bool().default(true), createdAt: date().generate(() => new Date()) }) class User extends Model.Typed<any>() {} ``` ### 2. Update TS Configuration Configure your `tsconfig.json` to tell TypeScript where to find the types that will be generated. **You only need to do this once** - no need to repeat this step for future models: ```json { "compilerOptions": { // ... your existing options }, "include": [ "src/**/*", ".verve/models.d.ts" ] } ``` ### 3. Generate Types Run the Verve CLI to generate TypeScript types from your model schema: ```bash npx verve ``` This automatically updates your model class: ```typescript // Before: Model.Typed<any>() class User extends Model.Typed<any>() {} // After: Model.Typed<'User'>() class User extends Model.Typed<'User'>() {} ``` ### 4. Keep Types in Sync Verve generates types from your actual schema definitions, but you should ensure these stay up to date. Consider adding a pre-commit hook or CI step: ```bash # Add to package.json scripts "scripts": { "pre-commit": "npx verve && git add ." } ``` This way your IDE will always have accurate type information and won't complain about missing or outdated types. ## Core Features ### 🔐 Field-Level Authorization Control exactly who can read or write each field: ```typescript @model({ // Public field - everyone can read/write name: text(), // Read-only for everyone except admins salary: number() .readable(true) .writable((context) => context.auth.role === Role.ADMIN), // Only readable by the owner or HR personalNotes: text().readable((context, employee) => context.auth.id === employee.id || context.auth.department === 'HR' ), // Never readable (sensitive data) encryptedData: text().readable(false) }) class Employee extends Model.Typed<'Employee'>() {} ``` ### Business Rule Validation Enforce business rules and domain invariants automatically: ```typescript @model({ // Single-field business rule validation age: number().validate([ (value) => value >= 18 && value <= 120 ]), // Single-field validation with multiple validators email: text().validate([ (value) => value.includes('@'), (value) => value.endsWith('placeholder.com') ]), // Cross-field validation (using other model fields) salary: number().validate([ (value, model) => { // Junior employees can't earn more than $80k if (model.level === 'junior') return value <= 80_000; // Senior employees must earn at least $100k if (model.level === 'senior') return value >= 100_000; return value > 0; } ]), level: option(['junior', 'mid', 'senior']), // Context-aware validation (using current user context) confidentialRating: number().validate([ (value, model, context) => { // Only HR can set ratings above 8 if (value > 8) return context.auth.department === 'HR'; // Managers can set ratings 1-8 if (value >= 1) return context.auth.role === 'manager' || context.auth.department === 'HR'; return false; } ]) }) class Employee extends Model.Typed<'Employee'>() {} ``` Single-field validation handles basic constraints, cross-field validation enforces complex business rules between fields, and context-aware validation adapts rules based on who's making the change. ```typescript @model({ // Business invariant: discount cannot exceed price discount: number().validate([ (value, model) => value <= model.price, (value) => value >= 0 ]), price: number().validate([ (value) => value > 0, (value) => Number.isFinite(value) ]) }) class Product extends Model.Typed<'Product'>() {} ``` Business invariants ensure your domain rules are always enforced, automatically **catching violations before they can corrupt your data**. ### 🎯 Method Authorization Secure your domain methods: ```typescript class BankAccount extends Model.Typed<'BankAccount'>() { // Only account owner can check balance @can((context, account) => context.auth.id === account.ownerId) getBalance() { return this.balance; } // Only admins can freeze accounts @can((context) => context.auth.role === Role.ADMIN) freeze() { this.isActive = false; } // Complex authorization logic @can((context, account) => { const isOwner = context.auth.id === account.ownerId; const isManager = context.auth.role === Role.MANAGER; const isSameBank = context.auth.bankId === account.bankId; return isOwner || (isManager && isSameBank); }) transfer(amount: number, targetAccount: string) { // Transfer logic } } ``` ### 🏗️ Context-Aware Security Set context for secure, request-scoped authorization: #### Node.js Server ```typescript import { Context } from 'verve' import express from 'express' // Set up proper request isolation for Node.js apps Context.useAsyncLocalStorage() const app = express() // Middleware to extract user context from request app.use((req, res, next) => { const userContext = { auth: { id: req.user?.id, role: req.user?.role, department: req.user?.department } } // Context automatically scoped to this request Context.run(userContext, () => { next() }) }) // Your route handlers app.get('/profile/:userId', (req, res) => { // Context automatically available, no context bleeding between requests const user = User.make({ id: req.params.userId, email: 'john@example.com' }) try { const sensitiveData = user.ssn // ❌ Unauthorized access res.json({ ssn: sensitiveData }) } catch (error) { res.status(403).json({ error: 'Unauthorized' }) } res.json({ email: user.email }) // ✅ Public field }) ``` #### Browser Environment Browser automatically uses global storage, no additional setup needed. If you want to be explicit: ```typescript import { Context } from 'verve' // Set context when user logs in function handleLogin(user) { Context.set({ auth: { id: user.id, role: user.role, permissions: user.permissions } }) } // Clear context when user logs out function handleLogout() { Context.reset() } // Context persists across your SPA until reset const user = User.make({ email: 'john@example.com' }) console.log(user.email) // Uses current logged-in user context ``` #### Any Node.js Framework ```typescript // Set up once at app startup Context.useAsyncLocalStorage() // Works with Fastify, Koa, NestJS, or any Node.js app Context.run(userContext, () => { // All Verve operations use this context const model = MyModel.from(data) model.someAuthorizedMethod() }); ``` **Why different adapters?** - **Node.js servers**: Need `AsyncLocalStorage` to prevent context leaking between concurrent requests - **Browsers/SPAs**: Global storage works fine since there's only one user per browser tab ## Field Types Verve supports rich field types with default/generated values and validation: ```typescript @model({ // Example of lazy validator name: text().validate.lazy([(v) => v.length < 20]), // By default all validators are eager email: text().validate([(v) => v.includes('@')]), // Numeric field with business rules price: number().validate([(v) => v > 0]), // Boolean field with defaults isActive: bool().default(true), // Date field with automatically generated date createdAt: date().generate(() => new Date()), // Enum field status: option(Status).default(Status.PENDING), // Array field tags: list<string>().default([]), // Object field metadata: record<{ theme: string; lang: string }>() .default({ theme: 'light', lang: 'en' }) }) class MyModel extends Model.Typed<'MyModel'>() {} ``` ### Basic Types ```typescript // ID field - for unique identifiers (usually strings) id() // Text field - for strings text() // Number field - for integers and floats number() // Boolean field - for true/false values bool() // Date field - for Date objects date() ``` ### Complex Types ```typescript // Option field - for enums and constrained values option(['draft', 'published', 'archived']) option(StatusEnum) // List field - for arrays list<string>() // Array of strings list<User>() // Array of User models // Record field - for objects record<{ name: string; age: number }>() ``` ### Field Modifiers Each field type supports different modifiers based on its capabilities: #### ID Field ```typescript id() .generate(() => crypto.randomUUID()) // Generate ID when creating new models .validate([(id) => id.length > 0]) // Validate ID format // Note: ID fields are only writable when model.isNew() - automatically enforced ``` #### Text Field ```typescript text() .nullable // Allow null values .default('Hello World') // Static default value .generate(() => crypto.randomUUID()) // Generate value on model creation .validate([(value) => value.length > 3]) // Validation rules .validate.only([(value) => value.includes('@')]) // Ignore global validators, use only these .validate.lazy([(value) => value.length < 1000]) // Lazy validation (not run immediately) .readable((context, model) => context.auth.role === 'admin') // Read authorization .writable((context, model) => context.auth.id === model.ownerId) // Write authorization ``` #### Number Field ```typescript number() .nullable // Allow null values .default(0) // Static default value .generate(() => Math.random()) // Dynamic generated value .validate([(value) => value > 0]) // Validation rules .readable((context, model) => context.auth.role === 'admin') .writable((context, model) => context.auth.department === 'finance') ``` #### Boolean Field ```typescript bool() .nullable // Allow null values .default(true) // Static default value .validate([(value) => value === true]) // Validation rules .readable((context, model) => context.auth.role === 'admin') .writable((context, model) => context.auth.id === model.ownerId) ``` #### Date Field ```typescript date() .nullable // Allow null values .generate(() => new Date()) // Generate value on model creation .validate([(value) => value > new Date('2000-01-01')]) // Validation rules .readable((context, model) => context.auth.role === 'admin') .writable((context, model) => context.auth.id === model.ownerId) // Note: Date fields don't support .default() - use .generate() instead ``` #### Option Field ```typescript option(['draft', 'published', 'archived']) .nullable // Allow null values .default('draft') // Static default value .validate([(value) => value !== 'archived']) // Additional validation .readable((context, model) => context.auth.role === 'admin') .writable((context, model) => context.auth.role === 'editor') ``` #### List Field ```typescript list<string>() .nullable // Allow null values .default([]) // Static default value .default(() => ['default', 'items']) // Dynamic default value .generate(() => [crypto.randomUUID()]) // Generate value on model creation .validate([(items) => items.length <= 10]) // Validation rules .readable((context, model) => context.auth.role === 'admin') .writable((context, model) => context.auth.id === model.ownerId) ``` #### Record Field ```typescript record<{ name: string; age: number }>() .nullable // Allow null values .default({ name: 'Anonymous', age: 0 }) // Static default value .default(() => ({ name: 'User', age: new Date().getFullYear() - 2000 })) // Dynamic default .generate(() => ({ id: crypto.randomUUID(), timestamp: Date.now() })) // Generate on creation .validate([(obj) => obj.name && obj.name.length > 0]) // Validation rules .readable((context, model) => context.auth.role === 'admin') .writable((context, model) => context.auth.id === model.ownerId) ``` ## Global Field Configuration Set up global behaviors that apply to all fields of a specific type across your entire application: ### Global Generators ```typescript import { IdField, TextField, DateField } from 'verve' // Set global ID generation for all id() fields IdField.setGlobalGenerator(() => crypto.randomUUID()) // Set global timestamp generation for all date() fields DateField.setGlobalGenerator(() => new Date()) // Now all models automatically use these generators @model({ id: id(), // Uses crypto.randomUUID() by default createdAt: date() // Uses new Date() by default }) class User extends Model.Typed<'User'>() {} ``` ### Global Validators ```typescript // Set global validation for all text() fields TextField.setGlobalValidator((value) => { if (typeof value !== 'string') return false return value.trim().length > 0 // No empty strings allowed }) // Set global validation for all date() fields DateField.setGlobalValidator((value) => { const minDate = new Date('1970-01-01') const maxDate = new Date('2100-12-31') return value >= minDate && value <= maxDate }); // Global validators run on ALL fields of that type @model({ name: text(), // Must pass TextField global validator email: text(), // Must pass TextField global validator birthDate: date() // Must pass DateField global validator }) class User extends Model.Typed<'User'>() {} ``` ### How Validators Combine ```typescript // Global validator applies to all text fields TextField.setGlobalValidator((value) => value.trim().length > 0); @model({ // ✅ Adds to global validators - BOTH will run name: text().validate([(value) => value.length <= 50]), // ✅ Adds to global validators - ALL will run email: text() .validate([ (value) => value.includes('@'), (value) => value.includes('.'), ]), // ❌ Ignores global validators - ONLY custom validator runs internalCode: text().validate.only([(value) => /^[A-Z0-9]+$/.test(value)]) }) class User extends Model.Typed<'User'>() {} // Validation execution order: // name: [global validator, length <= 50] // email: [global validator, includes '@', includes '.'] // description: [global validator, then lazy length check] // internalCode: [ONLY the regex validator - global ignored] ``` ### Practical Use Cases ```typescript // Security: Prevent XSS in all text fields // Note: Use more comprehensive regex to secure your text fields TextField.setGlobalValidator((value) => { return !/<script|javascript:|on\w+=/i.test(value) }) // Business rules: All dates must be reasonable (for our application use-case) DateField.setGlobalValidator((value) => { const now = new Date() const minDate = new Date(0) // 1970-01-01 const maxDate = new Date('2050-01-01') return value >= minDate && value <= maxDate }) // Consistency: All IDs follow same format IdField.setGlobalGenerator(() => crypto.randomUUID()) ``` **Key Benefits:** - **Consistency** - Same behavior across all models automatically - **Security** - Global validation catches issues everywhere - **DRY principle** - Define common rules once, apply everywhere - **Flexibility** - Use `.validate.only()` when you need exceptions ## Field Access Methods Every model has various helper methods that can help you retrieve or modify field values: ### Value Access ```typescript const user = User.make({ name: 'John', age: 30 }) // Get field value (throws if not valid/readable/initialized) user.name // 'John' // Unsafe get (returns undefined if the field is not initialized and bypasses all validators and checks) user.unsafeGet('name') // 'John' or undefined // Set field value (throws if not writable) user.set({ name: 'Jane' }) // Completely unset the value from the model's state (uninitialize) user.unset('name') ``` ### Value Checking ```typescript // Check if field is empty/present user.hasEmpty('name') // true if null/undefined or empty array/object user.hasPresent('name') // opposite of the isEmpty method // Check field validity user.hasValid('name') // true if passes all validators user.validate('name') // returns VerveErrorList related to 'name' field user.validate() // returns VerveErrorList (merged errors from all fields) ``` ### Generate Value ```typescript // Generate field value (for fields with lazy .generate()) user.generate('id') // generates new ID user.generate() // generates values for all fields that can be generated ``` **Prefer field methods to set values over direct assignment:** ```typescript // ✅ Recommended user.set({ email: 'new@example.com', name: 'Martin' }) // ⚠️ Also works but prefer above method user.email = 'new@example.com' user.name = 'Martin' ``` ## Model Types & Instantiation ### Model Type Definitions ```typescript // Untyped model - minimal type safety class User extends Model {} // Typed model before type generation // Use this when you need to re-generate types or don't have any generated types yet class User extends Model.Typed<any>() {} // Typed model with generated types class User extends Model.Typed<'User'>() {} ``` After running `npx verve`, your models get full type safety: ```typescript // Before: Model.Typed<any>() // After: Model.Typed<'User'>() - with complete type information ``` ### Model Instantiation **Critical difference between `make` and `from`:** ```typescript // make() - Creating NEW models // All fields passed to constructor are recorded as CHANGES // This includes generated and default fields // For example, lets assume the field types defines for the model: // id: id(), <-- generated // isActive: bool().default(false) <-- defualt const newUser = User.make({ name: 'John', email: 'john@example.com' }) newUser.isNew() // true newUser.getChanges() // { id: '1', name: 'John', email: 'john@example.com', isActive: false } // from() - Hydrating EXISTING models (from DB, API, etc.) // Only subsequent mutations are recorded as CHANGES // This would NOT execute generate and default const existingUser = User.from({ id: '123', name: 'John', email: 'john@example.com' }) existingUser.isExisting() // true existingUser.getChanges() // {} - no changes yet existingUser.set({ name: 'Jane' }) existingUser.getChanges() // { name: 'Jane' } - only the mutation // Manually generating fields on a model that was hydrated also fails existingUser.generate('id') // ❌ Field cannot be generated on existing model ``` #### Model Properties By default, all fields and their state is set on an instance of a model. Exception to this are non-readable properties. ```typescript @model({ name: text(), password: text().readable(false), // Sensitive information, always hidden }) class User extends Model.Typed<'User'>() {} // Let's hydrate the model and log it const user = User.from({ name: 'Martin', password: '123456' }) // Since password not readable, it's filtered out from the object properties console.log(user) // { name: 'Martin' } // Same for when you try to JSON stringify the object JSON.stringify(user) // '{"name":"Martin"}' ``` You may now think how to actually get the non-readable data for create/update operations. ```typescript // Let's say we want to create new user and after validation, we want to store it in the DB const user = User.make({ name: 'Martin', password: '123456' }) // How to get all the fields, including non-readable password? user.getChanges() // { name: 'Martin', password: '123456' } // Careful, 'getChanges' method won't give you any changes on models that were hydrated (existing) using 'from' const user = User.from({ name: 'Martin', password: '123456' }) user.getChanges() // {} // But works if I mutate the password now and try to get changes user.set({ password: '12345678' }) user.getChanges() // { password: '12345678' } ``` ## Model Instance Methods Every model instance provides these methods: ### Change Tracking ```typescript const user = User.from({ name: 'John', age: 30 }) user.set({ name: 'Jane', age: 31 }) user.set({ name: 'Joe' }) // Gets latest changes per field since hydration user.getChanges() // { name: 'Joe', age: 31 } // Gets detailed change log with all changes user.getChangeLog() // Array of change objects with timestamps including all changes (not just the latest field change) // Example after unsetting field user.unset('name') // This erases change log for 'name' field user.getChanges() // { age: 31 } user.getChangeLog() // Would not contain any changes on 'name' field // Check if model is new or existing user.isNew() // false user.isExisting() // true since model was created with 'from' method (hydrating existing model) ``` ### Field Selection ```typescript // Keep only specific fields (unset others) user.only(['name']) // Only name remains (+ automatically all fields that used id() field type) console.log(user) // User { id: '123', name: 'Martin' } // Remove specific fields user.except(['name']) console.log(user) // User { id: '123', isActive: true } // Note: ID fields are never removed by only() or except() ``` The `make` vs `from` distinction ensures you always know exactly what data has changed. ## Advanced Usage ### Dynamic Authorization Rules ```typescript @model({ // Authorization based on field value confidentialNotes: text().readable((context, doc, value) => { // Only show if user has clearance level >= document level return context.auth.clearanceLevel >= doc.securityLevel }), // Time-based access temporaryData: text().readable((context) => { const now = new Date(); const workHours = now.getHours() >= 9 && now.getHours() <= 17; return context.auth.role === Role.ADMIN || workHours }) }) class SecureDocument extends Model.Typed<'SecureDocument'>() {} ``` ## Error Handling Verve provides a comprehensive error handling system with structured error codes, customizable messages, and security-conscious error exposure. ### Error Types & Common Scenarios Verve throws clear, actionable errors for security violations and validation failures: ```typescript try { // Validation error - business rule violation const user = User.make({ age: 16 }) // ❌ Age must be >= 18 } catch (error) { console.log(error.message) // "FIELD_VALIDATOR_FAILED: Field 'age' validator 'ageValidator' failed on model 'User'" } try { // Authorization error - field not readable console.log(user.ssn) // ❌ Field not readable by current context } catch (error) { console.log(error.message) // "FIELD_NOT_READABLE: Field 'ssn' is not readable on model 'User'" } try { // Authorization error - method not authorized user.promoteToAdmin() // ❌ Only admins can promote } catch (error) { console.log(error.message) // "UNAUTHORIZED_METHOD_CALL: Unauthorized to call method 'promoteToAdmin'" } try { // Model instantiation error new User() // ❌ Direct instantiation not allowed } catch (error) { console.log(error.message) // "DIRECT_INSTANTIATION_NOT_ALLOWED: Direct instantiation not allowed. Use .make() or .from() instead." } ``` ### Error Checking & Handling Use the `VerveError` class to check for specific error types: ```typescript import { VerveError, ErrorCode } from 'verve' const errors = user.validate('ssn') // Returns VerveErrorList // Check presence of the errors if (errors.isPresent()) { // Do something with these errors, throw or log, prevent further logic to be executed // You can also check for specific error if (errors.contains(ErrorCode.FIELD_VALIDATOR_FAILED)) { // Invalid value provided } if (errors.contains(ErrorCode.FIELD_NOT_READABLE)) { // Unauthorized access } } // Check if errors list is empty if (errors.isEmpty()) { // No errors, good to go } ``` **You can also use validate method on the model to validate all fields:** ```typescript import { VerveError, ErrorCode } from 'verve' const errors = user.validate() // Returns VerveErrorList // This might contain validation errors for all fields that failed if (errors.isEmpty()) { // Execute your logic } ``` ### Custom Error Messages Override default error messages for better user experience or localization: ```typescript import { ErrorRegistry, ErrorCode } from 'verve' // Register custom error messages ErrorRegistry.register({ [ErrorCode.FIELD_NOT_READABLE]: 'You do not have permission to view this information.', [ErrorCode.FIELD_NOT_WRITABLE]: 'This field cannot be modified.', [ErrorCode.FIELD_VALIDATOR_FAILED]: 'The value you entered is not valid.', [ErrorCode.UNAUTHORIZED_METHOD_CALL]: 'You are not authorized to perform this action.', [ErrorCode.FIELD_NOT_NULLABLE]: 'This field is required and cannot be empty.' }) // Now all errors use your custom messages try { user.ssn // ❌ Not readable } catch (error) { console.log(error.message) // "FIELD_NOT_READABLE: You do not have permission to view this information." } ``` ### Message Templating Error messages support dynamic templating with field and model information: ```typescript // Some of the default messages use {{field}}, {{model}}, and other contextual data ErrorRegistry.register({ [ErrorCode.FIELD_VALIDATOR_FAILED]: "Field '{{field}}' validator '{{validator}}' failed on model '{{model}}'", [ErrorCode.FIELD_NOT_READABLE]: "Field '{{field}}' is not readable on model '{{model}}'", [ErrorCode.ASSOCIATION_INCOMPLETE]: 'You must call .to(...) after .associate({{from}})', }) // Templates are automatically populated with context try { user.set({ age: -5 }) // ❌ Validation fails } catch (error) { console.log(error.message) // "FIELD_VALIDATOR_FAILED: The value for 'age' is invalid on model User" } ``` ### Production-Safe Error Hiding Hide error codes in production to prevent information leakage: ```typescript import { ErrorRegistry } from 'verve' // In production, hide error codes from end users if (process.env.NODE_ENV === 'production') { ErrorRegistry.hideCodes() } // Now errors only show user-friendly messages without codes try { user.creditScore // ❌ Not readable } catch (error) { console.log(error.message) // Development: "FIELD_NOT_READABLE: Field 'creditScore' is not readable on model 'User'" // Production: "Field 'creditScore' is not readable on model 'User'" } ``` Or differentiate between production and development errors completely: ```typescript import { ErrorRegistry } from 'verve' // In production, hide error codes from end users if (process.env.NODE_ENV === 'production') { ErrorRegistry.register({ [ErrorCode.FIELD_NOT_READABLE]: "You're not authorized to read '{{field}}'", }) ErrorRegistry.hideCodes() } // Now errors only show user-friendly messages without codes or model information try { user.creditScore // ❌ Not readable } catch (error) { console.log(error.message) // Development: "FIELD_NOT_READABLE: Field 'creditScore' is not readable on model 'User'" // Production: "You're not authorized to read 'creditScore'" } ``` ### Error Logging & Monitoring ```typescript import { VerveError, ErrorCode } from 'verve' function logError(error: unknown) { if (error instanceof VerveError) { logVerveError(error) return } // Rest of your error logging logic } function logVerveError(error: VerveError) { // Log security violations for monitoring if (error.is(ErrorCode.UNAUTHORIZED_METHOD_CALL) || error.is(ErrorCode.FIELD_NOT_READABLE)) { logger.warn('Security violation:', { error: error.message, user: context.auth.id, timestamp: new Date().toISOString() }) } // Log validation failures for data quality monitoring if (error.is(ErrorCode.FIELD_VALIDATOR_FAILED)) { logger.info('Validation failure:', { error: error.message, user: context.auth.id }) } } ``` The error system ensures your application fails securely with actionable feedback while maintaining security in production environments. ## TypeScript Integration Full type safety with excellent IDE support: ```typescript const user = User.make({ email: 'test@example.com' }) // ✅ TypeScript knows these field types user.email // string user.age // number | null user.isActive // boolean // ✅ Method parameters are typed user.set({ email: 'new@example.com' }) // ✅ string user.set({ age: 25 }) // ✅ number user.set({ age: 'Hello' }) // ❌ TypeScript error user.set({ random: 'Something' }) // ❌ TypeScript error ``` ## Best Practices ### 1. **Defense in Depth** ```typescript // Layer multiple security checks @model({ creditScore: number() .readable((context, user) => context.auth.id === user.id) // Only owner .validate.only([(score) => score >= 300 && score <= 850]) // Valid range }) class CreditReport extends Model.Typed<'CreditReport'>() { @can((context, report) => context.auth.id === report.userId && // Must be owner context.auth.verified === true // Must be verified ) requestIncrease() { /* ... */ } } ``` ### 2. **Centralized Rules** ```typescript // Define reusable authorization rules const SecurityRules = { isOwner: (context: any, model: any) => context.auth.id === model.ownerId, isAdmin: (context: any) => context.auth.role === Role.ADMIN, isOwnerOrAdmin: (context: any, model: any) => SecurityRules.isOwner(context, model) || SecurityRules.isAdmin(context) } @model({ privateData: text().readable(SecurityRules.isOwnerOrAdmin) }) class SecureModel extends Model.Typed<'SecureModel'>() {} ``` ### 3. **Fail Secure** ```typescript // Always default to secure (no access) @model({ sensitiveField: text().readable(false), // Default: no access // Only grant specific permissions publicField: text().readable(true) }) class SecureByDefault extends Model.Typed<'SecureByDefault'>() {} ``` ## Roadmap We're continuously improving Verve to make domain modeling even more powerful and developer-friendly. Here's what's coming: ### 🎯 Custom Validator Errors - **Field-level custom errors** - Define specific error messages for validation failures - **Contextual error formatting** - Error messages that adapt based on user context and permissions ```typescript // Not implemented yet, just an idea 🚨 text().validate([ (value) => value.length > 3, { message: 'Name must be at least 4 characters long' } ]) ``` ### 🔧 Custom Field Types - **Type-specific utilities** - Field helpers tailored to each field type's unique needs - **Advanced validation helpers** - Common validation patterns as reusable helpers - **Field transformation utilities** - Built-in formatters and sanitizers ```typescript // Not implemented yet, just an idea 🚨 email() // Text field with built-in email validation currency() // Currency formatting and validation ``` Or by enhancing base fields types: ```typescript // Not implemented yet, just an idea 🚨 date().businessDays() // Business day calculations list().unique() // Ensure array items are unique ``` ### 🔗 In-Memory Association Loading - **Automatic association hydration** - Load related models from in-memory storage - **Lazy loading strategies** - Load associations on-demand for performance - **Circular reference handling** - Safe handling of complex model relationships ```typescript // Not implemented yet, just an idea 🚨 @model({ posts: list('Post').loadFrom('memory'), // Auto-load from in-memory store profile: record('Profile').lazy() // Load on first access }) class User extends Model.Typed<'User'>() {} ``` ### 🚀 Additional Features - **Advanced caching strategies** - Built-in caching for computed fields and associations - **Performance optimizations** - Zero-cost abstractions and faster field access - **Developer tooling** - Better IDE support and debugging utilities **Want to contribute or suggest features?** We'd love to hear from you! Please open an issue to discuss new ideas. ## License MIT