<img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/assets/rip.png" style="width:50px" /> <br>

# Rip Schema - @rip-lang/schema

**One definition. Three outputs. Zero drift.**

A schema language that generates TypeScript types, runtime validators, and SQL
from a single source of truth.

---

## Quick Start

**CLI — generate types and SQL from a schema file:**

```bash
# Generate both .d.ts and .sql
bun packages/schema/generate.js app.schema

# Generate TypeScript types only
bun packages/schema/generate.js app.schema --types

# Generate SQL DDL only (with DROP TABLE)
bun packages/schema/generate.js app.schema --sql --drop

# Output to a specific directory
bun packages/schema/generate.js app.schema --outdir ./generated
```

**Programmatic — load, generate, and validate:**

```javascript
import { Schema } from '@rip-lang/schema'

const schema = Schema.load('app.schema')

const ts  = schema.toTypes()    // → TypeScript declarations
const sql = schema.toSQL()      // → SQL DDL (CREATE TABLE, INDEX, TYPE)
const zod = schema.toZod()      // → Zod schemas (validation + type inference)

const user = schema.create('User', { name: 'Alice', email: 'alice@co.com' })
const errors = user.$validate() // → null if valid, array of errors if not
```

**ORM — schema-driven models with a live database:**

```coffee
import { Schema } from '@rip-lang/schema/orm'

schema = Schema.load './app.schema', import.meta.url
schema.connect 'http://localhost:4213'

User = schema.model 'User',
  greet: -> "Hello, #{@name}!"
  computed:
    identifier: -> "#{@name} <#{@email}>"
    isAdmin:    -> @role is 'admin'

user = User.first!()
console.log user.identifier     # Alice <alice@example.com>
```

---

## The Problem

Every real application needs three things:

1. **TypeScript types** — for compile-time safety and IDE support
2. **Runtime validators** — for rejecting bad inputs in production
3. **Database schema** — for tables, constraints, indexes, and migrations

Today, you write each one separately:

```
types/user.ts         →  interface User { name: string; email: string; ... }
schemas/user.ts       →  z.object({ name: z.string().min(1).max(100), ... })
prisma/schema.prisma  →  model User { name String @db.VarChar(100) ... }
```

Three files. Three syntaxes. Three things to keep in sync manually. They drift
apart — silently, inevitably — and you find out at the worst possible time.

This isn't a tooling failure. It's a structural one. Each tool solves a
different problem at a different layer:

| Tool | What it solves | Where it works | What it can't do |
|------|---------------|----------------|------------------|
| TypeScript | Compile-time safety | IDE, build step | Vanishes at runtime |
| Zod | Runtime validation | API boundaries | No database awareness |
| Prisma | Database persistence | Migrations, queries | No runtime validation |

You can't eliminate this by picking one tool. TypeScript types are erased before
your code runs. Zod doesn't know about indexes. Prisma can't enforce "must be a
valid email" at the API layer.

But you can eliminate it by writing a definition that's richer than any single
tool — and generating all three from it.

---

## The Answer

Write one schema:

```coffee
@enum Role: admin, user, guest

@model User
  name!     string, [1, 100]
  email!#   email
  role      Role, [user]
  bio?      text, [0, 1000]
  active    boolean, [true]

  @belongs_to Organization
  @has_many Post

  @timestamps
  @index [role, active]
```

Get three outputs:

**TypeScript types** (generated by `emit-types.js`):

```typescript
export enum Role {
  admin = "admin",
  user = "user",
  guest = "guest",
}

export interface User {
  id: string;
  /** @minLength 1 @maxLength 100 */
  name: string;
  /** @unique */
  email: string;
  /** @default "user" */
  role: Role;
  /** @minLength 0 @maxLength 1000 */
  bio?: string;
  /** @default true */
  active: boolean;
  organizationId: string;
  organization?: Organization;
  posts?: Post[];
  createdAt: Date;
  updatedAt: Date;
}
```

**Runtime validation** (built-in — no Zod needed):

```javascript
import { Schema } from '@rip-lang/schema'

const schema = Schema.load('app.schema')

const user = schema.create('User', { name: 'Alice', email: 'alice@co.com' })
// user.role === 'user' (default applied)
// user.createdAt === Date (auto-set)

const errors = user.$validate()
// null if valid, otherwise:
// [{ field: 'name', error: 'required', message: 'name is required' }]
```

**SQL DDL** (generated by `emit-sql.js`):

```sql
CREATE TYPE role AS ENUM ('admin', 'user', 'guest');

CREATE TABLE users (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name             VARCHAR(100) NOT NULL,
  email            VARCHAR NOT NULL UNIQUE,
  role             role DEFAULT 'user',
  bio              TEXT,
  active           BOOLEAN DEFAULT true,
  organization_id  UUID NOT NULL REFERENCES organizations(id),
  created_at       TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at       TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_role_active ON users (role, active);
```

One source of truth. Always in sync. Impossible to drift.

---

## Why Not Just TypeScript?

TypeScript types are the weakest candidate for a single source of truth:

1. **Erased at runtime.** `JSON.parse()` returns `any`. Network responses,
   database rows, and form submissions are all untyped at the moment you need
   protection most. TypeScript can't help because it no longer exists.

2. **Can't express constraints.** There is no way to say "string between 1 and
   100 characters" or "must be a valid email" in a TypeScript type. You need
   runtime code for that — which means you need a second system.

3. **Can't model persistence.** Indexes, unique constraints, foreign keys,
   cascade rules, column types, precision — none of these exist in TypeScript's
   type system. You need a third system.

You could bolt metadata onto TypeScript with decorators, JSDoc tags, or branded
types. But then TypeScript isn't the source of truth — your annotation layer
is. And you've built a schema language anyway, just a worse one bolted onto a
host that fights you.

Rip Schema skips the pretense. It's a schema language purpose-built to capture
everything all three layers need, and it generates each layer's native format
directly.

---

## Schema Syntax

### Fields

The basic unit is a field definition:

```
name[modifiers] type[, [constraints]][, { attributes }]
```

Examples:

```coffee
name!     string, [1, 100]          # Required string, 1-100 chars
email!#   email                     # Required + unique email
bio?      text, [0, 1000]           # Optional text, max 1000 chars
role      Role, [user]              # Enum with default value
score     integer, [0, 100, 50]     # Range + default
tags      string[]                  # Array of strings
data      json, [{}]                # JSON with default
zip!      string, [/^\d{5}$/]       # Regex pattern
phone!    string, [10, 15], { mask: "(###) ###-####" }  # With UI hints
```

### Modifiers

Modifiers appear between the field name and the type:

| Modifier | Meaning | TypeScript | SQL |
|----------|---------|-----------|-----|
| `!` | Required | non-optional | `NOT NULL` |
| `#` | Unique | `@unique` JSDoc | `UNIQUE` |
| `?` | Optional | `field?: T` | nullable |

Modifiers can be combined: `email!# email` means required and unique.

### Constraints

Constraints appear in square brackets after the type:

| Syntax | Meaning | Example |
|--------|---------|---------|
| `[min, max]` | Range (string length or numeric value) | `[1, 100]` |
| `[default]` | Default value | `[true]` |
| `[min, max, default]` | Range + default | `[0, 100, 50]` |
| `[/regex/]` | Pattern match | `[/^\d{5}$/]` |
| `[{}]` | Default empty object | `[{}]` |
| `[->]` | Default from function | `[->]` |

### Primitive Types

| Type | TypeScript | SQL (DuckDB) |
|------|-----------|-------------|
| `string` | `string` | `VARCHAR` |
| `text` | `string` | `TEXT` |
| `integer` | `number` | `INTEGER` |
| `number` | `number` | `DOUBLE` |
| `boolean` | `boolean` | `BOOLEAN` |
| `date` | `Date` | `DATE` |
| `datetime` | `Date` | `TIMESTAMP` |
| `json` | `unknown` | `JSON` |
| `uuid` | `string` | `UUID` |

### Special Types

These carry built-in validation:

| Type | Validates | TypeScript | SQL |
|------|-----------|-----------|-----|
| `email` | RFC 5322 format | `string` | `VARCHAR` |
| `url` | Valid URL | `string` | `VARCHAR` |
| `phone` | E.164 or regional | `string` | `VARCHAR` |
| `uuid` | UUID format | `string` | `UUID` |

---

## Definitions

### Enums

```coffee
# Inline
@enum Role: admin, user, guest

# Block with explicit values
@enum Status
  pending:   0
  active:    1
  suspended: 2
  deleted:   3

# Block with string values
@enum Priority
  low:      "low"
  medium:   "medium"
  high:     "high"
  critical: "critical"
```

### Types

Types define reusable structures without database backing — useful for embedded
objects, API payloads, and shared shapes:

```coffee
@type Address
  street!   string, [1, 200]
  city!     string, [1, 100]
  state!    string, [2, 2]
  zip!      string, [/^\d{5}(-\d{4})?$/]

@type ContactInfo
  phone?    phone
  email?    email
  fax?      phone
```

Types generate TypeScript interfaces and runtime validators but no SQL tables.

### Models

Models are database-backed entities with full schema, validation, relationships,
and lifecycle:

```coffee
@model User
  name!       string, [1, 100]
  email!#     email
  password!   string, [8, 100]
  role!       Role, [user]
  avatar?     url
  bio?        text, [0, 1000]
  settings    json, [{}]
  active      boolean, [true]

  address?    Address                 # Embedded type

  @timestamps                         # createdAt, updatedAt
  @softDelete                         # deletedAt

  @index email#                       # Unique index
  @index [role, active]               # Composite index
  @index name                         # Non-unique index

  @belongs_to Organization
  @has_many Post
```

Models generate TypeScript interfaces, runtime validators, and SQL DDL.

Computed properties and custom validation live in model code (see
[Computed Fields](#computed-fields) and [Validation](#validation) below).

### Relationships

```coffee
@belongs_to User                      # Creates user_id foreign key
@belongs_to Category, { optional: true }
@has_one Profile                      # (or @one Profile)
@has_many Post                        # (or @many Post)
@has_many Comment                     # (or @many Comment)
```

`@one` and `@many` are shorthand aliases for `@has_one` and `@has_many`.

### Links (Universal Temporal Associations)

```coffee
@link "admin", Organization           # User can be linked as admin to Organization
@link "mentor", User                  # Self-referential: User mentors another User
```

Links are stored in a shared `links` table (auto-generated in DDL) with temporal
windowing (`when_from`, `when_till`), enabling role-based, time-bounded, any-to-any
relationships between models. The ORM provides `link()`, `unlink()`, `links()`, and
`linked()` methods for querying and managing links.

### Indexes and Directives

```coffee
@index email#                         # Unique index on one field
@index [role, active]                 # Composite index on multiple fields
@index name                           # Non-unique index

@timestamps                           # Adds createdAt, updatedAt
@softDelete                           # Adds deletedAt
@include Auditable                    # Include mixin fields
```

### Computed Fields

Computed properties are defined in your model code, not in the `.schema`
file — they're behavior, not data:

```coffee
User = schema.model 'User',
  computed:
    displayName: -> "#{@name} <#{@email}>"
    isAdmin:     -> @role is 'admin'
```

Computed fields are accessible as properties (no parens needed), appear in
`toJSON()` output, but have no SQL column.

### Validation

Type constraints (`!`, `#`, `?`, `[min, max]`, enum membership) are
validated automatically by the runtime engine. Custom cross-field validation
lives in your model code:

```coffee
User = schema.model 'User',
  beforeSave: ->
    throw new Error('Password needs uppercase') unless /[A-Z]/.test(@password)
```

This keeps the `.schema` file focused on structural definitions and lets
behavior live where it belongs — in code.

### Mixins

Reusable field groups:

```coffee
@mixin Timestamps
  createdAt!   datetime
  updatedAt!   datetime

@mixin SoftDelete
  deletedAt?   datetime

@mixin Auditable
  @include Timestamps
  @include SoftDelete
  createdBy?   integer
  updatedBy?   integer

@model Post
  title!    string, [1, 200]
  content!  text
  @include Auditable
```

---

## Beyond Data: Widgets, Forms, and State

The schema language is designed to extend beyond the data layer into UI and
application state — the same "define once, generate everything" principle
applied to presentation. The syntax is defined; the code generators are
future work (see [Future](#future) below).

### Widgets

```coffee
@widget DataGrid
  columns!       Column[]
  pageSize       integer, [25]
  selectionMode  SelectionMode, [single]
  sortable       boolean, [true]

  @events onSelect, onSort, onAction
```

### Forms

```coffee
@form UserForm: User
  name  { x: 0, y: 0, span: 2, label: "Full Name" }
  email { x: 0, y: 1 }
  role  { x: 0, y: 2, widget: dropdown }
  bio   { x: 0, y: 3, widget: textarea, rows: 3 }

  @actions
    save   { primary: true }
    cancel {}
```

### State

```coffee
@state App
  currentUser?   User
  theme          string, ['light']
  sidebarOpen    boolean, [true]

  @computed
    isLoggedIn -> @currentUser?
    isAdmin    -> @currentUser?.role is 'admin'

  @actions
    login  { async: true }
    logout {}
```

---

## How It Works

Schemas are parsed by a Solar-generated SLR(1) parser into S-expressions — a
lightweight tree structure that any code generator can walk:

```
app.schema  →  lexer  →  parser  →  S-expressions  →  ┬── emit-types.js  →  app.d.ts
                                                       ├── emit-sql.js    →  app.sql
                                                       ├── emit-zod.js    →  app.zod.ts
                                                       └── runtime.js     →  validate()
```

The S-expression for a model looks like:

```javascript
["model", "User", null, [
  ["field", "name",  ["!"],      "string", [[1, 100]], null],
  ["field", "email", ["!", "#"], "email",  null,       null],
  ["field", "role",  [],         "Role",   [["user"]], null],
  ["timestamps"],
  ["index", ["role", "active"], false]
]]
```

Each output target is a simple tree-walker over this structure. Adding a new
target — OpenAPI, JSON Schema, Prisma, GraphQL — means writing one more walker.
The schema definition never changes.

This architecture means the schema language is not coupled to any specific
output format. It's a **representation of your domain** that happens to be
renderable into whatever format each layer of your stack needs.

---

## Runtime ORM

Beyond code generation, Rip Schema includes a Schema-centric ORM. The
schema object itself creates models — one call wires fields, types,
constraints, methods, and computed properties from the `.schema` file:

```coffee
import { Schema } from '@rip-lang/schema/orm'

schema = Schema.load './app.schema', import.meta.url
schema.connect 'http://localhost:4213'

User = schema.model 'User',
  greet: -> "Hello, #{@name}!"
  displayRole: -> @role.charAt(0).toUpperCase() + @role.slice(1)
  computed:
    identifier: -> "#{@name} <#{@email}>"
    isAdmin:    -> @role is 'admin'

Post = schema.model 'Post',
  computed:
    summary: -> "#{@title} (#{@status})"
```

That's the entire model definition. Fields, types, constraints, table name,
primary key, foreign keys, timestamps, soft-delete, and relations are all
derived from the schema. You only write behavior.

### Queries

```coffee
user  = User.find!(id)                    # Find by ID
user  = User.first!()                     # First record
users = User.all!()                       # All records
users = User.where!(active: true).all!()  # Filtered
count = User.count!()                     # Count

# Chainable
users = User
  .where!('active = ?', true)
  .orderBy!('name')
  .limit!(10)
  .all!()
```

### Records

```coffee
# Schema fields — properties
user.name                    # Read
user.name = 'Alice'          # Write (automatic dirty tracking)

# Computed — properties (no parens)
user.identifier              # "Alice <alice@example.com>"
user.isAdmin                 # true

# Methods — called with parens
user.greet()                 # "Hello, Alice!"
user.displayRole()           # "Admin"

# State
user.$isNew                  # Not yet persisted?
user.$dirty                  # Changed field names
user.$changed                # Has any changes?
user.$data                   # Raw data snapshot

# Lifecycle
user.$validate()             # → null or [{ field, error, message }]
user.save()                  # INSERT or UPDATE (dirty fields only)
user.delete()                # DELETE
user.softDelete()            # Set deleted_at (if @softDelete)
user.restore()               # Clear deleted_at
user.reload()                # Refresh from database
user.toJSON()                # Serialize (includes computed fields)
```

### Relations

Relations are derived from the `@belongs_to`, `@has_many` (or `@many`), and
`@has_one` (or `@one`) directives in the schema. Each relation becomes an async
method on instances:

```coffee
# Given: User @many Post, Post @belongs_to User

# Lazy loading — one query per call
posts  = user.posts!()           # → [Post, Post, ...]
author = post.user!()            # → User
```

All models registered with `schema.model` are automatically discoverable — no
manual wiring needed.

### Links (Temporal Associations)

Links use a shared `links` table for named, time-bounded, any-to-any associations:

```coffee
# Given: User @link "admin", Organization

# Create a link
user.link! "admin", org

# Query outgoing links
user.links! "admin"              # → [{ role, targetType, targetId, ... }]

# Query incoming: who is admin of this org?
admins = User.linked! "admin", org  # → [User, User, ...]

# End a link (preserves history by setting when_till)
user.unlink! "admin", org
```

### Eager Loading

Eliminate N+1 queries by pre-loading relations in batch:

```coffee
# 2 queries instead of N+1
users = User.include!('posts').all!()
for u in users
  posts = u.posts!()             # instant — no query, data already loaded
  console.log "#{u.name}: #{posts.length} posts"

# Chainable with other query methods
active = User.include!('posts').where!(active: true).all!()

# Multiple relations
users = User.include!('posts', 'organization').all!()

# Works with first() too
user = User.include!('posts').first!()
```

Under the hood, `include` executes one batch query per relation using
`WHERE fk IN (...)` — the standard 2-query strategy used by Rails and Prisma.
Lazy loading still works for any relation not pre-loaded.

### Soft Delete

Models with `@softDelete` in the schema automatically filter deleted records
from all queries. No configuration needed — the ORM reads it from the schema:

```coffee
# Queries automatically exclude soft-deleted records
posts = Post.all!()                  # only non-deleted posts

# Soft-delete a record (sets deleted_at, no actual DELETE)
post.softDelete!()

# Explicitly include soft-deleted records
all = Post.withDeleted!().all!()

# Restore a soft-deleted record
post.restore!()
```

Models without `@softDelete` are unaffected — `delete()` still performs a
real `DELETE` as before.

### Lifecycle Hooks

Define hooks in your model to run logic before or after persistence:

```coffee
User = schema.model 'User',
  beforeSave: ->
    @email = @email.toLowerCase()
  afterCreate: ->
    console.log "Welcome, #{@name}!"
  beforeDelete: ->
    console.log "Goodbye, #{@name}"
  computed:
    identifier: -> "#{@name} <#{@email}>"
```

Available hooks: `beforeSave`, `afterSave`, `beforeCreate`, `afterCreate`,
`beforeUpdate`, `afterUpdate`, `beforeDelete`, `afterDelete`.

- Hooks are called with `this` bound to the instance
- Before hooks run before validation — normalize data (lowercase, trim) and constraints are checked on the final values
- Before hooks can return `false` to abort the operation
- Hooks can be async
- `beforeDelete`/`afterDelete` fire on both `delete()` and `softDelete()`

### Transactions

Wrap multi-model operations in a transaction for atomicity:

```coffee
schema.transaction! ->
  user = User.create!(name: 'Alice', email: 'alice@co.com')
  post = Post.create!(title: 'Hello', userId: user.id)
  # If either fails, both roll back
```

If the callback throws, all changes are rolled back. If it succeeds, all
changes are committed. The return value of the callback is returned from
`transaction()`.

### Zod Schemas

Generate Zod validation schemas from the same source of truth:

```coffee
# Programmatic
schema = Schema.load 'app.schema'
zod = schema.toZod()     # → Zod source with all schemas

# CLI
bun generate.js app.schema --zod              # → app.zod.ts
bun generate.js app.schema --zod --stdout     # → print to stdout
```

Each model produces three schemas:

```typescript
export const UserSchema       = z.object({ ... })  // Full record
export const UserCreateSchema = z.object({ ... })  // Input for creation
export const UserUpdateSchema = z.object({ ... })  // Input for updates (all optional)
export type User       = z.infer<typeof UserSchema>
export type UserCreate = z.infer<typeof UserCreateSchema>
export type UserUpdate = z.infer<typeof UserUpdateSchema>
```

Constraints from the schema are mapped directly to Zod refinements:
`string [1, 100]` becomes `z.string().min(1).max(100)`, email types get
`.email()`, UUIDs get `.uuid()`, and defaults flow through as `.default()`.
Enum types reference their Zod enum schema. Nested `@type` definitions are
embedded as `z.object()` references. Relation `has_many`/`has_one` fields
are omitted since Zod schemas target input validation, not query results.

### Factory

Schema-driven fake data generation — zero configuration, zero dependencies:

```coffee
# Single record
user = User.factory!()              # create 1 (persisted)
user = User.factory!(0)             # build 1 (not persisted)

# Batch
users = User.factory!(5)            # create 5 (persisted, array)
users = User.factory!(-3)           # build 3 (not persisted, array)

# With overrides
admins = User.factory!(3, role: 'admin')
```

The factory knows what to generate from the schema — field names, types,
constraints, enums, and defaults:

| Field | Schema | Generated |
|-------|--------|-----------|
| `name!` | `string, [1, 100]` | `"Emma Diaz"` |
| `email!#` | `email` | `"kate3@staging.app"` |
| `role` | `Role, ["viewer"]` | `"viewer"` (uses default) |
| `bio?` | `text, [0, 500]` | Lorem paragraph or null |
| `active` | `boolean, [true]` | `true` (uses default) |

Field-name hinting makes the data realistic — `name` generates person names,
`email` generates emails, `city` generates cities, `slug` generates slugs.
No faker library needed.

For models that need custom logic, pass a `faker` function:

```coffee
User = schema.model 'User',
  faker: -> { status: Fake.pick ["Active", "Active", "Active", "Closed"] }
```

### Validation

```coffee
errors = user.$validate()

# null if valid, otherwise:
# [
#   { field: 'name',  error: 'required', message: 'name is required' },
#   { field: 'email', error: 'type',     message: 'email must be a valid email' },
#   { field: 'role',  error: 'enum',     message: 'role must be one of: admin, user, guest' },
# ]
```

Error types: `required`, `type`, `enum`, `min`, `max`, `pattern`, `nested`.

### Multi-file Layout

In a real application, each model lives in its own file:

```coffee
# db.rip — load schema once
import { Schema } from '@rip-lang/schema/orm'
export schema = Schema.load './app.schema', import.meta.url
schema.connect process.env.DB_URL or 'http://localhost:4213'

# models/user.rip — one model per file
import { schema } from '../db.rip'
export User = schema.model 'User',
  greet: -> "Hello, #{@name}!"
  computed:
    identifier: -> "#{@name} <#{@email}>"

# models/post.rip
import { schema } from '../db.rip'
export Post = schema.model 'Post',
  computed:
    summary: -> "#{@title} (#{@status})"

# app.rip — use models with relations
import { User } from './models/user.rip'
import { Post } from './models/post.rip'
user  = User.first!()
posts = user.posts!()            # lazy loads related posts
```

---

## Architecture

```
packages/schema/
├── grammar.rip     # Solar grammar definition (schema → S-expressions)
├── lexer.js        # Indentation-aware tokenizer
├── parser.js       # Generated SLR(1) parser
├── runtime.js      # Schema registry, validation, model factory
├── emit-types.js   # AST → TypeScript interfaces and enums
├── emit-sql.js     # AST → SQL DDL (CREATE TABLE, INDEX, TYPE)
├── emit-zod.js     # AST → Zod validation schemas
├── errors.js       # Beautiful parse error formatting
├── generate.js     # CLI: rip-schema generate app.schema
├── orm.js          # ActiveRecord-style ORM
├── faker.js        # Compact fake data generator (field-name hinting)
├── index.js        # Public API entry point
├── SCHEMA.md       # Full specification and design details
└── README.md       # This file

packages/db/
├── db.rip          # DuckDB HTTP server
└── lib/duckdb.mjs  # Native bindings
```

The schema package and database package are independent:

- **@rip-lang/schema** — Parse schemas, validate data, generate TypeScript
  types, SQL DDL, and Zod schemas, build domain models
- **@rip-lang/db** — Database server (DuckDB with native FFI bindings, served
  over HTTP)

Everything is opt-in. You can use any layer independently:

| You need... | You use... | Dependencies |
|-------------|-----------|-------------|
| Parse schema files | `Schema.load()` | None |
| Runtime validation | `schema.validate()`, `schema.create()` | None |
| TypeScript types | `schema.toTypes()` | None |
| SQL DDL | `schema.toSQL()` | None |
| Zod schemas | `schema.toZod()` | None |
| ORM with database | `schema.model()` + `@rip-lang/db` | DuckDB |

The ORM connects to the database over HTTP, keeping the layers cleanly
separated. You can use schema parsing and code generation without the ORM, and
you can use the ORM without the code generators.

---

## Testing

### Prerequisites

```bash
bun bin/rip packages/db/db.rip :memory:   # start rip-db (in-memory)
```

### Code Generation (`examples/app-demo.rip`)

Parses `app.schema` (a blog platform with enums, types, and models) and
validates all three output targets from a single source file.

```bash
rip packages/schema/examples/app-demo.rip
```

| What                          | Validates                                                 |
|-------------------------------|-----------------------------------------------------------|
| **Parse**                     | `Schema.load` parses 2 enums, 1 type, 4 models            |
| **TypeScript generation**     | `schema.toTypes()` produces interfaces, enums, JSDoc       |
| **SQL DDL generation**        | `schema.toSQL()` produces CREATE TABLE / CREATE TYPE        |
| **Valid instance**            | `schema.create('User', {...})` applies defaults, passes    |
| **Missing required field**    | Catches missing `email`                                    |
| **Invalid email**             | Catches `not-an-email` format                              |
| **String max length**         | Catches name > 100 chars                                   |
| **Invalid enum**              | Catches `superadmin` not in `Role` enum                    |
| **Nested type validation**    | Catches multiple violations in `Address` sub-object        |
| **Enum + integer defaults**   | `Post` gets correct `status` and `viewCount` defaults      |
| **File output**               | Writes `generated/app.d.ts` and `generated/app.sql`        |

### ORM with Live Database (`examples/orm-example.rip`)

Connects to `rip-db`, creates tables from schema-generated DDL, seeds
data, and exercises the full Schema-centric ORM.

```bash
rip packages/schema/examples/orm-example.rip
```

| What                          | Validates                                                 |
|-------------------------------|-----------------------------------------------------------|
| **Schema load**               | `Schema.load './app.schema'` parses and registers models   |
| **DDL generation**            | `schema.toSQL()` produces dependency-ordered DDL           |
| **Setup**                     | DROP/CREATE TABLE via `/sql` endpoint, seed users + posts  |
| **Model definition**          | `schema.model 'User'` + `schema.model 'Post'` wire fields |
| **Find first**                | `User.first()` returns a user with all schema fields       |
| **Find by email**             | `User.where(email: ...)` filters correctly                 |
| **Computed properties**       | `user.identifier` and `user.isAdmin` derive from fields    |
| **All users**                 | `User.all()` returns all 5 records                         |
| **Where (object style)**      | `User.where(role: 'editor')` filters correctly             |
| **Where (SQL style)**         | `User.where('active = ?', true)` with parameterized query  |
| **Chainable query**           | `.where().orderBy().limit()` chains produce correct SQL     |
| **Count**                     | `User.count()` returns 5                                   |
| **Instance methods**          | `user.greet()` and `user.displayRole()` work               |
| **Dirty tracking**            | Mutation of `name` sets `$dirty` and `$changed`            |
| **Relation: hasMany**         | `user.posts()` returns related posts                       |
| **Relation: belongsTo**       | `post.user()` returns the author                           |
| **Eager loading**             | `User.include('posts').all()` batch-loads in 2 queries     |
| **Lifecycle hooks**           | `beforeSave` normalizes email, `afterCreate` logs creation |
| **Transaction rollback**      | `schema.transaction!` rolls back on error, count unchanged |
| **Soft delete**               | `softDelete()` hides, `withDeleted()` includes, `restore()` recovers |
| **Factory: build**            | `User.factory(0)` builds with realistic fake data (not persisted)    |
| **Factory: batch create**     | `User.factory(3, role: 'editor')` creates 3 with overrides           |
| **Validation**                | Missing `name` and invalid `email` caught by `$validate()` |

### Full Test Suite

```bash
bun test/runner.js test/rip     # 1255 tests, 100% passing
```

---

## Future

Features that would extend the system but aren't needed yet:

- **`@computed` / `@validate` in the DSL** — Cross-field validation and
  computed properties work today in model code via `schema.model` options.
  Moving them into the `.schema` file itself would allow code generators to
  emit them too. Not urgent — the model-code approach is clean.
- **Migration diffing** — The DDL generator produces target state, not
  migration paths. Use dbmate, Flyway, or Prisma Migrate for now. Schema-
  aware diffing (compare two ASTs, emit `ALTER TABLE`) is a natural extension.
- **Widget / Form / State systems** — The schema language is designed to
  extend beyond the data layer (see [SCHEMA.md](./SCHEMA.md) for the full
  vision). Widget definitions, form layouts, and reactive state management
  would apply the same "define once, generate everything" principle to UI.
- **Per-schema connection state** — `schema.connect()` sets a module-level
  URL. Two schemas can't connect to different databases. Not a problem for
  single-database apps; revisit when multi-database support is needed.
- **MySQL / SQLite / PostgreSQL** — DuckDB is the starting point. The SQL
  emitter is a simple AST walker; adding dialect variants is straightforward.

---

## Background

Rip Schema is part of the [Rip](https://github.com/shreeve/rip-lang) language
ecosystem. It draws on ideas from:

- **SPOT/Sage** — Enterprise schema framework that proved unified definitions
  work at scale (complex medical systems with 2000+ line schemas)
- **ActiveRecord** — Ruby's ORM pattern: models as rich domain objects
- **Zod** — Runtime validation with composable schemas
- **Prisma** — Database schema as code with generated client types
- **TypeScript** — Type inference and structural typing
- **ASN.1** — Formal notation for describing data structures

The key insight is that types, validation, and persistence are three views of
the same underlying reality — the shape of your data. Writing them separately
creates drift. Writing them once and generating the rest eliminates it.

---

## See Also

- [SCHEMA.md](./SCHEMA.md) — Full specification, syntax details, and design
  rationale
- [examples/](./examples/) — Working code examples
- [grammar.rip](./grammar.rip) — The Solar grammar that parses schema files
- [emit-types.js](./emit-types.js) — TypeScript declaration generator
- [emit-sql.js](./emit-sql.js) — SQL DDL generator (DuckDB)
- [generate.js](./generate.js) — CLI entry point
