JSPM

@martinmilo/verve

1.1.0
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 2
  • Score
    100M100P100Q32780F
  • License MIT

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

Package Exports

  • @martinmilo/verve
  • @martinmilo/verve/dist/index.js

This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (@martinmilo/verve) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

Readme

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

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
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
@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
@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:

// 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

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

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:

{
  "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:

npx verve

This automatically updates your model class:

// 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:

# 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:

@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:

@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.

@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:

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

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:

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

// 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:

@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

// 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

// 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

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

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

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

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

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

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

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

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

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

// 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

// 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

// 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

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

// 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

// 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:

// ✅ 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

// 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:

// Before: Model.Typed<any>() 
// After: Model.Typed<'User'>() - with complete type information

Model Instantiation

Critical difference between make and from:

// 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.

@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.

// 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

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

// 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

@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:

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:

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:

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:

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:

// 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:

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:

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

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:

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

// 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

// 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

// 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
// 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
// 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:

// 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
// 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