VibORM

Architecture

Design principles, data flow, and critical architectural rules for VibORM's layered system

Design Principles

1. Types From Runtime Validation

VibORM infers TypeScript types from runtime validation schemas - a single source of truth for both compile-time types AND runtime validation.

const user = s.model({
  id: s.string().id().ulid(),
  name: s.string(),
  email: s.string().unique(),
});

// Types are inferred from the schema definition
// No code generation needed
const users = await orm.user.findMany({
  where: { name: "Alice" }  // ← Fully typed!
});

2. Dual Validation

Every operation is validated twice:

  1. Compile-time - TypeScript catches type errors
  2. Runtime - VibORM's validation engine validates actual data

3. Separation of Concerns

The architecture separates what to query from how to express it:

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  Schema Layer   │────▶│  Client Layer   │────▶│  Query Engine   │
│  (Definition)   │     │  (Operations)   │     │  (WHAT)         │
└─────────────────┘     └─────────────────┘     └─────────────────┘


                               ┌─────────────────┐     ┌─────────────┐
                               │    Database     │◀────│  Adapters   │
                               │    (Execute)    │     │  (HOW)      │
                               └─────────────────┘     └─────────────┘

Critical Architectural Rules

Rule 1: Query Engine / Adapter Separation

The Golden Rule: Query engine decides WHAT to query. Adapters decide HOW to express it.

This enables VibORM to support PostgreSQL, MySQL, and SQLite with a single codebase. The query engine builds query structure; adapters translate to database-specific syntax.

Rule 2: Immutable State for Type Tracking

Every field modifier returns a NEW instance:

s.string()           // StringField<{type: "string"}>
  .nullable()        // StringField<{type: "string", nullable: true}>  ← NEW instance
  .default("hello")  // StringField<{..., default: "hello"}>           ← NEW instance

TypeScript tracks the State generic through each transformation. If we mutated the field instead, the type system couldn't track changes.

Rule 3: Lazy Evaluation for Circular References

Relations use thunks () => Model to break circular dependencies:

const user = s.model({
  posts: s.oneToMany(() => post),  // Thunk defers evaluation
});
const post = s.model({
  author: s.manyToOne(() => user),
});

JavaScript can't reference a variable before it's declared. Thunks defer resolution until the model is actually used.

Data Flow

Query Execution

Layer Boundaries

Schema Layers (L1-L5)

These layers handle definition and validation - no SQL generation.

LayerPurpose
L1: ValidationStandard Schema V1 primitives (v.*)
L2: FieldsField type definitions with State generic
L3: Query SchemasValidation schemas for where/create/update
L4: RelationsRelationship definitions between models
L5: Schema ValidationDefinition-time correctness checking

Execution Layers (L6-L8)

These layers handle query building and execution.

LayerPurpose
L6: Query EngineDatabase-agnostic query structure (WHAT)
L7: AdaptersDatabase-specific SQL syntax (HOW)
L8: DriversConnection management and execution

Interface Layer (L9)

The client layer provides the public API and type inference.

Management Layer (L10)

Migrations handle schema evolution independently.

File Structure

src/
├── validation/           # L1: Validation primitives
├── schema/               # L2-L5: Schema system
│   ├── fields/           # L2: Field types
│   ├── model/schemas/    # L3: Query schemas
│   ├── relation/         # L4: Relations
│   └── validation/       # L5: Schema validation
├── query-engine/         # L6: Query engine
├── adapters/             # L7: Database adapters
├── drivers/              # L8: Database drivers
├── client/               # L9: ORM client
└── migrations/           # L10: Schema migrations

On this page