# rads-db

> TypeScript-first universal data access library. Define entities as decorated classes, get strongly-typed CRUD methods, filtering, pagination, JOINs across any storage backend. Works on both server and browser.

## Setup

```bash
npm install rads-db
```

Create `rads.config.ts` at the project root:

```typescript
import { defineRadsConfig, schemaFromFiles } from 'rads-db/config'

export default defineRadsConfig({
  dataSources: {
    db: {
      schema: schemaFromFiles('./entities'), // folder with your entity classes
    },
  },
})
```

Run codegen after every schema change (generates TypeScript types and metadata):

```bash
npx rads
```

Automate in `package.json`:

```json
{
  "scripts": {
    "dev": "npx rads && <your-dev-command>",
    "build": "npx rads && <your-build-command>"
  }
}
```

## Defining Entities

Each entity is a TypeScript class in the entities folder. Use decorators from `rads-db`:

```typescript
import { entity, field, ui, validate, computed, precomputed, keepHistory } from 'rads-db'
import type { Relation, InverseRelation } from 'rads-db'
import type { TcRole } from './TcRole'
import type { TcPost } from './TcPost'

@entity()
@ui<typeof TcUser>({ name: 'User', namePlural: 'Users', nameField: 'name', captionFields: ['id', 'name'], group: 'People' })
export class TcUser {
  id!: string

  @validate<TcUser, any, string>({ preset: 'email' })
  @ui({ name: 'Email Address', hint: 'Used for login' })
  email!: string

  name!: string
  age?: number
  isActive?: boolean
  tags: string[] = []

  // Relation: only { id } is stored; use include: { tcRole: {} } to load full object
  tcRole!: Relation<TcRole>

  // Relation with denormalized fields stored alongside the id
  tcRoleWithName!: Relation<TcRole, 'name'>

  // Computed inverse relation — not stored, derived at query time
  tcPosts?: InverseRelation<'TcPost'>

  @computed()
  fullLabel?: string  // computed server-side, not stored

  @precomputed({ preset: 'eventSourcing' })
  status?: string     // precomputed and stored when source events change

  @keepHistory()
  statusHistory?: string  // keeps full history of changes
}
```

### Decorator reference

| Decorator | Target | Purpose |
|-----------|--------|---------|
| `@entity(args?)` | class | Marks class as a database entity; `args.driver` overrides default driver |
| `@ui(args)` | class or field | Display hints for rads-ui (name, icon, captionFields, nameField, etc.) |
| `@validate(args)` | class or field | Validation rules (presets, regex, min/maxLength, etc.) |
| `@field(args?)` | field | Marks a relation field; `args.relation` points to the related class |
| `@computed()` | field | Computed at query time, never stored |
| `@precomputed()` | field | Computed and stored; recalculated when source data changes |
| `@keepHistory()` | field | Stores full change history |

### Relation types

```typescript
Relation<T>                   // stores { id } only
Relation<T, 'field1'|'field2'> // stores { id, field1, field2 } (denormalized)
InverseRelation<'EntityName'> // reverse lookup — computed, not stored
```

### Field validation presets (string)

`text` | `html` | `markdown` | `alpha` | `alphanum` | `number` | `decimalNumber` | `email` | `icon` | `imageUrl` | `fileUrl` | `absoluteUrl` | `relativeUrl` | `phoneNumber` | `datetime` | `date` | `time` | `timeInterval` | `duration` | `hexColor`

## Creating the DB instance

```typescript
import { createRadsDb } from 'rads-db'

const db = createRadsDb('db')
// db.tcUser, db.tcPost, db.tcRole, … — one property per entity (lowerFirst of class name)
```

## CRUD Methods

Every entity exposes the same set of methods on `db.<entityHandle>`:

### get — fetch one record

```typescript
const user = await db.tcUser.get({ where: { id: 'user1' } })
// returns undefined if not found
```

### getAll — fetch all matching records

```typescript
const users = await db.tcUser.getAll({ where: { isActive: true } })
// returns T[]
```

### getMany — paginated fetch

```typescript
const page = await db.tcUser.getMany({
  where: { isActive: true },
  maxItemCount: 20,
  cursor: previousPage.cursor, // pass cursor from previous response for next page
  orderByArray: ['name_asc', 'age_desc'],
})
// returns { nodes: T[], cursor: string | undefined }
// cursor === undefined means no more pages
```

### getAgg — aggregate queries

```typescript
const agg = await db.tcUser.getAgg({
  where: { isActive: true },
  agg: ['_count', 'age_min', 'age_max', 'age_sum'],
})
// agg._count: number; agg.age_min: number | undefined; etc.
```

### put — create or update one record (upsert by id)

```typescript
const saved = await db.tcUser.put({ id: 'user1', name: 'Alice', tcRole: { id: 'role1' } })
// returns the full saved record
// missing optional fields stay unchanged; null erases the value
```

### putMany — batch upsert

```typescript
const saved = await db.tcUser.putMany([
  { id: 'user1', name: 'Alice', tcRole: { id: 'role1' } },
  { id: 'user2', name: 'Bob',   tcRole: { id: 'role1' } },
])
```

### construct — create in-memory object with defaults + a new UUID

```typescript
const newUser = db.tcUser.construct({ name: 'Draft' })
// newUser.id is a fresh UUID; defaults from field declarations are applied
```

## Where Filters

Where clauses are fully typed. Every primitive field supports operators via suffix:

```typescript
await db.tcUser.getAll({
  where: {
    name: 'Alice',                    // exact match
    name_startsWith: 'Al',
    name_istartsWith: 'al',           // case-insensitive
    name_contains: 'lic',
    name_icontains: 'LIC',
    name_endsWith: 'ice',
    name_in: ['Alice', 'Bob'],
    name_notIn: ['Charlie'],
    age_gt: 18,
    age_gte: 18,
    age_lt: 65,
    age_lte: 65,
    isActive: true,

    // Logical combinators
    _and: [{ isActive: true }, { age_gte: 18 }],
    _or:  [{ name: 'Alice' }, { name: 'Bob' }],
    _not: { isActive: false },

    // Nested object filter
    tcRole: { id: 'role1' },
  },
})
```

## Include (JOINs)

Load related entities inline. Works across different databases/drivers:

```typescript
const post = await db.tcPost.get({
  where: { id: 'post1' },
  include: {
    tcUser: {},                    // load full TcUser object
    tcUser: { tcRole: {} },        // nested: load tcUser and its tcRole
    tcComments: { _pick: ['id', 'text'] }, // load only specific fields of relation
  },
})
```

## Field selection with `_pick`

```typescript
const users = await db.tcUser.getAll({
  include: { _pick: ['id', 'name'] }, // return only id and name
})
```

## TypeScript utility types

```typescript
import type { Get, Put } from 'rads-db'

// Get — shape returned by read operations (all fields non-nullable)
type UserView = Get<'TcUser'>
// With relations and field selection:
type UserWithRole = Get<'TcUser', { tcRole: {}; _pick: ['id', 'name'] }>

// Put — shape accepted by write operations (all fields except id are optional; null erases a value)
type UserWrite = Put<'TcUser'>
```

**Form state pattern** — use `Put` for editable state; `Get` is assignable to `Put`:

```typescript
const data = await db.tcUser.get({ where: { id } })
const form: Put<'TcUser'> = { ...data }  // Get is assignable to Put ✓
form.age = null                           // null = erase the field
await db.tcUser.put(form)
```

## Context (second argument to every method)

Pass `RadsRequestContext` as the second argument to control behaviour per-request:

```typescript
import type { RadsRequestContext } from 'rads-db'

const ctx: RadsRequestContext = {
  getUser: () => ({ id: 'user1', role: 'admin' }),
  dryRun: true,         // no changes written; effects returned as logs
  noCache: true,        // bypass caching layer
  excludeFeatures: ['softDelete'], // skip specific features
}

const result = await db.tcUser.put(data, ctx)
```

## Features

Features hook into the get/put pipeline. Pass them to `createRadsDb`:

```typescript
import { createRadsDb } from 'rads-db'
import softDelete from 'rads-db/features/softDelete'
import eventSourcing from 'rads-db/features/eventSourcing'
import cache from 'rads-db/features/cache'

const db = createRadsDb('db', {
  features: [
    softDelete(),
    eventSourcing(),
    cache(),
  ],
})
```

### Built-in features

| Feature | Import | What it does |
|---------|--------|-------------|
| `softDelete` | `rads-db/features/softDelete` | Filters out records where `isDeleted: true` automatically |
| `eventSourcing` | `rads-db/features/eventSourcing` | Rebuilds aggregate from event log; entity needs `@precomputed({ preset: 'eventSourcing' })` |
| `cache` | `rads-db/features/cache` | Adds a caching layer; bypass with `ctx.noCache = true` |

### Custom feature

```typescript
import type { RadsFeature } from 'rads-db'

const auditLog = (): RadsFeature => ({
  name: 'auditLog',
  afterPut(items, ctx, computedContext) {
    for (const { doc, oldDoc } of items) {
      console.log('changed', computedContext.typeName, doc.id)
    }
  },
})
```

## Drivers

Configure per data-source in `rads.config.ts` or pass to `createRadsDb`:

| Import | Storage |
|--------|---------|
| `rads-db/drivers/memory` | In-memory (default; great for tests) |
| `rads-db/drivers/sql` | MSSQL / MySQL / PostgreSQL (via mssql / mysql2 / pg) |
| `rads-db/drivers/azureCosmos` | Azure Cosmos DB |
| `rads-db/drivers/azureStorageBlob` | Azure Blob Storage |
| `rads-db/drivers/indexedDb` | Browser IndexedDB |
| `rads-db/drivers/restApi` | REST API (used by `createRadsDbClient`) |

```typescript
import sql from 'rads-db/drivers/sql'

const db = createRadsDb('db', {
  driver: sql({
    server: 'localhost',
    database: 'mydb',
    authentication: { type: 'default', options: { userName: 'sa', password: 'pass' } },
  }),
})
```

## Client-side usage (browser)

```typescript
import { createRadsDbClient } from 'rads-db'

// Uses fetch to talk to the server REST API; same methods as server-side db
const db = createRadsDbClient('db', { driver: { baseUrl: '/api' } })

const users = await db.tcUser.getAll({ where: { isActive: true } })
```

## REST API / Server endpoints

Expose all entities over HTTP using `getRestRoutes`:

```typescript
// h3 / Nitro (Nuxt)
import { defineEventHandler, getMethod, readBody } from 'h3'
import { createRadsDb } from 'rads-db'
import { getRestRoutes } from 'rads-db/integrations/restEndpoints'

const db = createRadsDb('db')
const routes = getRestRoutes({ db, prefix: '/api/' })

export default defineEventHandler(async event => {
  const path = event.path.split('?')[0]
  const method = getMethod(event)
  const handler = routes[path]?.[method]
  if (!handler) return
  const body = method !== 'GET' ? await readBody(event) : undefined
  return handler({ body, context: event, headers: {} })
})
```

Generated routes per entity (e.g. `TcUser` → handle `tcUser`):

| Path | Method | Description |
|------|--------|-------------|
| `/api/tcUser` | `POST` | `db.tcUser.get(body)` |
| `/api/tcUser` | `PUT` | `db.tcUser.put(body.data)` |
| `/api/tcUser/list` | `POST` | `db.tcUser.getMany(body)` |
| `/api/tcUser/list` | `PUT` | `db.tcUser.putMany(body.data)` |
| `/api/tcUser/agg` | `POST` | `db.tcUser.getAgg(body)` |

## OpenAPI / Swagger

```typescript
import { getOpenApiSpec } from 'rads-db/integrations/getOpenApiSpec'

const spec = getOpenApiSpec(
  { db, prefix: '/api/' },
  { title: 'My App API', version: '1.0.0' },
)
```

## Utilities

```typescript
import { merge, diff, cleanEntity } from 'rads-db'

merge(target, source)    // deep merge source into target
diff(oldObj, newObj)     // returns changed fields only
cleanEntity(entity)      // strips undefined/null fields
```
