# 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 && ", "build": "npx rads && " } } ``` ## 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({ name: 'User', namePlural: 'Users', nameField: 'name', captionFields: ['id', 'name'], group: 'People' }) export class TcUser { id!: string @validate({ 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 // Relation with denormalized fields stored alongside the id tcRoleWithName!: Relation // 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 // stores { id } only Relation // 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.`: ### 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' } }, ]) ``` ### Optimistic concurrency — `_etag` / `_ifMatch` Every record returned by `get` / `getMany` / `getAll` carries `_etag`, the version of the stored document. Pass it back as `_ifMatch` to write only if nobody changed the document in the meantime: ```typescript const job = await db.tcJob.get({ where: { id: 'job1' } }) try { await db.tcJob.put({ id: 'job1', status: 'done', _ifMatch: job._etag }) } catch (e) { if (e.statusCode !== 412) throw e // the document changed since it was read - re-read and decide again } ``` - Opt-in: without `_ifMatch` the last write wins. Spreading a read record (`{ ...job }`) does not make the write conditional. - Server-side only, supported by the `azureCosmos` and `memory` drivers; `putMany` checks each document separately (bulk is not transactional). - Any failed bulk operation throws (status code in `statusCode`), deleting an already missing document is not a failure. - The memory driver emulates `_etag` as a non-enumerable property, so it does not show up in `toEqual` or JSON. ### 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 ```