{"id":35377831,"url":"https://github.com/foo-ogawa/litedbmodel.ts","last_synced_at":"2026-05-26T23:05:34.989Z","repository":{"id":331179251,"uuid":"1125591654","full_name":"foo-ogawa/litedbmodel.ts","owner":"foo-ogawa","description":"A lightweight, SQL-friendly TypeScript ORM for PostgreSQL, MySQL, and SQLite with transparent N+1 prevention and type-safe queries","archived":false,"fork":false,"pushed_at":"2026-03-20T12:53:48.000Z","size":616,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-21T02:50:35.564Z","etag":null,"topics":["database","javascript","lightweight","mysql","nodejs","npm-package","orm","postgresql","sql","sqlite","type-safe","typescript"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":false,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/foo-ogawa.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-12-31T02:16:41.000Z","updated_at":"2026-03-20T12:53:50.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/foo-ogawa/litedbmodel.ts","commit_stats":null,"previous_names":["foo-ogawa/litedbmodel.ts"],"tags_count":32,"template":false,"template_full_name":null,"purl":"pkg:github/foo-ogawa/litedbmodel.ts","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/foo-ogawa%2Flitedbmodel.ts","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/foo-ogawa%2Flitedbmodel.ts/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/foo-ogawa%2Flitedbmodel.ts/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/foo-ogawa%2Flitedbmodel.ts/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/foo-ogawa","download_url":"https://codeload.github.com/foo-ogawa/litedbmodel.ts/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/foo-ogawa%2Flitedbmodel.ts/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33542350,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"ssl_error","status_checked_at":"2026-05-26T15:22:15.568Z","response_time":63,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["database","javascript","lightweight","mysql","nodejs","npm-package","orm","postgresql","sql","sqlite","type-safe","typescript"],"created_at":"2026-01-02T04:52:42.700Z","updated_at":"2026-05-26T23:05:34.983Z","avatar_url":"https://github.com/foo-ogawa.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# litedbmodel\n\n[![npm version](https://img.shields.io/npm/v/litedbmodel.svg)](https://www.npmjs.com/package/litedbmodel)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\n**[📖 API Documentation](https://github.com/foo-ogawa/litedbmodel.ts/blob/main/docs/api/README.md)**\n\nlitedbmodel is a lightweight, SQL-friendly TypeScript ORM for PostgreSQL, MySQL, and SQLite.\nIt is designed for production systems where you care about **predictable SQL**, **explicit performance control**, and **operational safety** (replication lag, N+1, accidental full scans).\n\n\n## Philosophy\n\n### SQL is not the enemy — opacity is.\nMost ORMs hide SQL behind abstractions that are hard to debug and hard to tune.\nlitedbmodel keeps SQL visible and controllable: generated queries are intentionally simple, and complex cases use real SQL via `query()` / `execute()`.\n\n### Make performance the default, not a post-mortem\n- Lazy relations are supported, but **N+1 is prevented automatically** via batch loading.\n- Per-parent limiting is done at the **SQL level** (efficient “top-N per group” patterns).\n- Write operations default to **no RETURNING** for throughput; request PKs via `PkeyResult` only when needed.\n\n### Production safety over convenience magic\n- Supports **reader/writer routing** for read replicas and replication-lag-aware reads.\n- Write operations (`create/createMany, update/updateMany, delete`) require an explicit **transaction boundary**.\n- Configurable **hard limits** detect accidental over-fetching early.\n\n### Refactoring-friendly, without sacrificing SQL control\n- Column references are **symbol-based** (`Model.column`) so IDE rename/find-references work.\n- Conditions are **type-safe tuples** (`[Column, value]`), with a `sql` tagged template for operators (`[sql\\`${Col} \u003e ?\\`, value]`). An ESLint plugin catches mistakes TS cannot.\n\n\u003e See [Design Philosophy](./docs/BENCHMARK.md#litedbmodels-design-philosophy) for detailed comparison with query-centric ORMs.\n\n## Key Features\n\n### SQL Control \u0026 Modeling\n- Predictable generated SQL (readable, hand-written style)\n- Raw SQL escape hatch: `Model.query()` / `DBModel.execute()`\n- Query-based models for complex reads (aggregations, JOINs, CTEs)\n\n### Performance by Default\n- Transparent N+1 prevention (automatic batch loading for lazy relations)\n- SQL-level per-parent limit for relations\n- Subqueries: IN/EXISTS with correlated conditions\n\n### Operational Readiness\n- Reader/Writer separation with sticky-writer reads after transactions (replication-lag-aware)\n- Transactions with retry options (e.g., deadlock retry)\n- Safety guards: `findHardLimit` / `hasManyHardLimit`\n\n### Developer Experience\n- Symbol-based columns + tuple conditions for refactoring safety\n- Declarative `SKIP` pattern for optional fields/conditions\n- Middleware for cross-cutting concerns (logging, auth, tenant isolation)\n- Multi-database support (portable tuple API; raw SQL is dialect-dependent)\n\n## When litedbmodel is a good fit\n\nChoose litedbmodel if you:\n- Build **large or high-throughput** services where SQL tuning and explain plans matter\n- Want ORM ergonomics, but refuse to lose the ability to write/own SQL\n- Operate with **read replicas** and care about replication lag and routing rules\n- Need safe defaults against “oops, loaded 10M rows” and N+1 regressions\n- Prefer a model-centric approach (list/detail + relations) with predictable behavior\n\n## When it may NOT be a good fit\n\nlitedbmodel may be a poor fit if you:\n- Want a “fully abstracted” ORM that hides SQL entirely\n- Prefer a query-builder DSL as the primary interface (rather than SQL/tuple conditions)\n- Need database-agnostic portability for complex raw SQL (dialect differences are real)\n\n## Non-goals\n\nNon-goals are deliberate trade-offs to keep SQL predictable and operations safe.\nlitedbmodel is intentionally **not** trying to be a “do-everything” ORM.\n\n- **100% database-agnostic SQL**: complex queries are expected to use real SQL, and SQL dialect differences are real.\n- **Migrations as a built-in feature**: schema migrations are out of scope (use your preferred migration tool).\n- **Hiding SQL behind a large abstraction layer**: we prioritize predictable SQL over a fully abstracted API.\n- **Automatic eager-loading everywhere**: relations are lazy by default; performance characteristics should stay explicit and controllable.\n\n---\n\n## Installation\n\n```bash\nnpm install litedbmodel reflect-metadata\n\n# Plus your database driver:\nnpm install pg            # PostgreSQL\nnpm install mysql2        # MySQL\nnpm install better-sqlite3  # SQLite\n```\n\n### Code Generation (optional)\n\n[**litedbmodel-gen**](https://www.npmjs.com/package/litedbmodel-gen) generates model column definitions from SQL DDL (`schema.sql`). Column definitions inside markers are auto-updated when the schema changes; hand-written code (relations, methods, exports) is preserved.\n\n```bash\nnpm install -D litedbmodel-gen embedoc\nnpx embedoc init \u0026\u0026 npx litedbmodel-gen init\nnpx embedoc generate --datasource schema   # scaffold model files\nnpx embedoc build                          # sync column definitions\n```\n\n## Quick Start\n\n```typescript\nimport 'reflect-metadata';\nimport { DBModel, model, column } from 'litedbmodel';\n\n// 1. Define model\n@model('users')\nclass UserModel extends DBModel {\n  @column() id?: number;\n  @column() name?: string;\n  @column() email?: string;\n  @column() is_active?: boolean;\n}\nexport const User = UserModel.asModel();  // Adds type-safe column references\n\n// 2. Configure database\nDBModel.setConfig({\n  host: 'localhost',\n  database: 'mydb',\n  user: 'user',\n  password: 'pass',\n  // driver: 'mysql',    // for MySQL\n  // driver: 'sqlite',   // for SQLite (use database: './data.db')\n});\n\n// 3. CRUD operations\nawait User.create([\n  [User.name, 'John'],\n  [User.email, 'john@example.com'],\n]);\nawait User.update([[User.id, 1]], [[User.name, 'Jane']]);\nawait User.delete([[User.is_active, false]]);\n\n// Read operations\nconst users = await User.find([[User.is_active, true]]);\nconst john = await User.findOne([[User.email, 'john@example.com']]);\n\n// With returning: true → get PkeyResult for re-fetching\nconst created = await User.create([...], { returning: true });\nconst [newUser] = await User.findById(created);\n```\n\n---\n\n## Model Options\n\nThe `@model` decorator accepts optional configuration for default behaviors:\n\n```typescript\n@model('entries', {\n  order: () =\u003e Entry.created_at.desc(),      // DEFAULT_ORDER\n  filter: () =\u003e [[Entry.is_deleted, false]], // FIND_FILTER (auto-applied)\n  select: 'id, title, created_at',           // SELECT_COLUMN\n  updateTable: 'entries_writable',           // UPDATE_TABLE_NAME (for views)\n})\nclass EntryModel extends DBModel {\n  @column() id?: number;\n  @column() title?: string;\n  @column() created_at?: Date;\n  @column.boolean() is_deleted?: boolean;\n}\nexport const Entry = EntryModel.asModel();\n```\n\n| Option | Type | Description |\n|--------|------|-------------|\n| `order` | `() =\u003e OrderSpec` | Default ORDER BY for `find()` |\n| `filter` | `() =\u003e Conds` | Auto-applied WHERE conditions |\n| `select` | `string` | Default SELECT columns |\n| `updateTable` | `string` | Table name for INSERT/UPDATE |\n| `group` | `() =\u003e Column \\| string` | Default GROUP BY |\n\n\u003e **Note:** Options using model columns (`order`, `filter`, `group`) require lazy evaluation `() =\u003e` because the model isn't fully defined when the decorator runs.\n\n---\n\n## Column Decorators\n\n### Auto-Inferred Types\n\nTypes are inferred from TypeScript property types:\n\n```typescript\n@column() id?: number;           // Number conversion\n@column() name?: string;         // No conversion\n@column() is_active?: boolean;   // Boolean conversion\n@column() created_at?: Date;     // DateTime conversion\n@column() large_id?: bigint;     // BigInt conversion\n```\n\n### Column Options\n\n```typescript\n@column('db_column_name') prop?: string;         // Custom column name (string shorthand)\n@column({ columnName: 'db_col' }) prop?: string; // Custom column name (object form)\n@column({ primaryKey: true }) id?: number;       // Mark as primary key\n@column({ primaryKey: true, columnName: 'user_id' }) id?: number; // Both options\n```\n\n| Option | Type | Description |\n|--------|------|-------------|\n| `columnName` | `string` | Database column name (defaults to property name) |\n| `primaryKey` | `boolean` | Mark as part of primary key (for `getPkey()`) |\n\n### Explicit Types (for arrays/JSON/UUID)\n\nUse explicit type decorators when auto-inference isn't sufficient:\n\n```typescript\n@column.date() birth_date?: string;         // Date only (YYYY-MM-DD string)\n@column.datetime() updated_at?: Date;       // DateTime with timezone\n@column.boolean() is_active?: boolean;      // Explicit boolean\n@column.number() amount?: number;           // Explicit number\n@column.uuid() id?: string;                 // UUID with auto-casting (PostgreSQL)\n@column.stringArray() tags?: string[];      // String array\n@column.intArray() scores?: number[];       // Integer array\n@column.numericArray() prices?: number[];   // Numeric/decimal array\n@column.booleanArray() flags?: boolean[];   // Boolean array\n@column.datetimeArray() dates?: Date[];     // DateTime array\n@column.json\u003cSettings\u003e() settings?: Settings; // JSON with type\n```\n\n### Date vs DateTime: Important Distinction\n\nDatabases distinguish between date-only and datetime columns. `@column.date()` returns a **`string`** (`YYYY-MM-DD`), not a `Date` object, because a calendar date has no timezone concept:\n\n| DB Column Type | Decorator | TypeScript Type | Example |\n|----------------|-----------|-----------------|---------|\n| `TIMESTAMP` / `DATETIME` | `@column()` or `@column.datetime()` | `Date` | `created_at`, `updated_at` |\n| `DATE` | `@column.date()` | `string` | `birth_date`, `settlement_date` |\n\n**⚠️ Important**: `@column()` with `Date` type is treated as `TIMESTAMP/DATETIME`. For date-only columns (no time component), you **must** use `@column.date()` explicitly.\n\n```typescript\n// ✅ Correct usage\n@column() created_at?: Date;              // TIMESTAMP column (datetime → Date)\n@column.datetime() updated_at?: Date;     // TIMESTAMP column (datetime → Date)\n@column.date() settlement_date?: string;  // DATE column (date only → 'YYYY-MM-DD')\n\n// ❌ Wrong: will cause type mismatch errors\n@column() settlement_date?: Date;         // Treated as TIMESTAMP, not DATE!\n```\n\n**Serialization behavior** (PostgreSQL):\n- `@column()` / `@column.datetime()` → ISO 8601 UTC: `2024-06-15T10:30:00.000Z`\n- `@column.date()` → date string: `2024-06-15`\n\n### Date Utility Functions\n\n`formatLocalDate` and `formatUTCDate` convert a `Date` object to a `YYYY-MM-DD` string. Use these when constructing date values for queries or conditions:\n\n```typescript\nimport { formatLocalDate, formatUTCDate } from 'litedbmodel';\n\n// Format using local timezone (matches @column.date() behavior)\nformatLocalDate(new Date());  // '2024-06-15' (in local timezone)\n\n// Format using UTC\nformatUTCDate(new Date());    // '2024-06-15' (in UTC)\n\n// Typical use: query with today's local date\nconst today = formatLocalDate(new Date());\nconst meals = await Meal.find([Meal.date, today]);\n```\n\n---\n\n## CRUD Operations\n\n### PkeyResult Type\n\nWrite operations can optionally return a `PkeyResult` object:\n\n```typescript\ninterface PkeyResult {\n  key: Column[];       // Key column(s) used to identify rows\n  values: unknown[][]; // 2D array of key values\n}\n\n// Single PK example\n{ key: [User.id], values: [[1], [2], [3]] }\n\n// Composite PK example\n{ key: [TenantUser.tenant_id, TenantUser.id], values: [[1, 100], [1, 101]] }\n```\n\n**Default behavior:** `returning: false` — returns `null` for better performance.  \n**With `returning: true`:** Returns `PkeyResult` with affected primary keys.\n\n\u003e **Note:** `PkeyResult.key` always contains primary key column(s), regardless of `keyColumns` used in `updateMany`.\n\n### create / createMany\n\n```typescript\n// Default: returns null (no RETURNING)\nawait User.create([\n  [User.name, 'John'],\n  [User.email, 'john@example.com'],\n]);\n\n// With returning: true → returns PkeyResult\nconst result = await User.create([\n  [User.name, 'John'],\n  [User.email, 'john@example.com'],\n], { returning: true });\n// result: { key: [User.id], values: [[1]] }\n\n// Multiple records\nconst result = await User.createMany([\n  [[User.name, 'John'], [User.email, 'john@example.com']],\n  [[User.name, 'Jane'], [User.email, 'jane@example.com']],\n], { returning: true });\n// result: { key: [User.id], values: [[1], [2]] }\n\n// Fetch created records if needed\nconst [user] = await User.findById(result);\n```\n\n### update / updateMany\n\n```typescript\n// Default: returns null (no RETURNING)\nawait User.update(\n  [[User.status, 'pending']],        // conditions\n  [[User.status, 'active']],         // values\n);\n\n// With returning: true → returns PkeyResult\nconst result = await User.update(\n  [[User.status, 'pending']],\n  [[User.status, 'active']],\n  { returning: true }\n);\n// result: { key: [User.id], values: [[1], [2], [3]] }\n\n// Bulk update with different values per row\nconst result = await User.updateMany([\n  [[User.id, 1], [User.name, 'John'], [User.email, 'john@example.com']],\n  [[User.id, 2], [User.name, 'Jane'], [User.email, 'jane@example.com']],\n], { keyColumns: [User.id], returning: true });\n// result: { key: [User.id], values: [[1], [2]] }\n\n// Fetch updated records if needed\nconst users = await User.findById(result);\n```\n\n**Generated SQL for updateMany:**\n\n| Database | SQL |\n|----------|-----|\n| PostgreSQL | `UPDATE ... FROM UNNEST($1::int[], $2::text[], ...) AS v(...) WHERE t.id = v.id` |\n| MySQL 8.0.19+ | `UPDATE ... JOIN (VALUES ROW(?, ?, ?), ...) AS v(...) ON ... SET ...` |\n| SQLite 3.33+ | `WITH v(...) AS (VALUES (...), ...) UPDATE ... FROM v WHERE ...` |\n\n### delete\n\n```typescript\n// Default: returns null (no RETURNING)\nawait User.delete([[User.is_active, false]]);\n\n// With returning: true → returns PkeyResult\nconst result = await User.delete([[User.is_active, false]], { returning: true });\n// result: { key: [User.id], values: [[4], [5]] }\n```\n\n### findById\n\nFetch records by primary key. Accepts `PkeyResult` format for efficient batch loading:\n\n```typescript\n// Single record\nconst [user] = await User.findById({ values: [[1]] });\n\n// Multiple records\nconst users = await User.findById({ values: [[1], [2], [3]] });\n\n// Composite PK\nconst [entry] = await TenantUser.findById({\n  values: [[1, 100]]  // [tenant_id, id]\n});\n\n// Use with update/delete result\nconst result = await User.update(...);\nconst users = await User.findById(result);\n```\n\n**Generated SQL:**\n\n| Database | Single PK | Composite PK |\n|----------|-----------|--------------|\n| PostgreSQL | `WHERE id = ANY($1::int[])` | `WHERE (col1, col2) IN (SELECT * FROM UNNEST(...))` |\n| MySQL | `WHERE id IN (?, ?, ?)` | `JOIN (VALUES ROW(...), ...) AS v ON ...` |\n| SQLite | `WHERE id IN (?, ?, ?)` | `WITH v AS (VALUES ...) ... JOIN v ON ...` |\n\n### Upsert (ON CONFLICT)\n\n```typescript\n// Insert or ignore\nawait User.create(\n  [[User.name, 'John'], [User.email, 'john@example.com']],\n  { onConflict: User.email, onConflictIgnore: true }\n);\n\n// Insert or update\nawait User.create(\n  [[User.name, 'John'], [User.email, 'john@example.com']],\n  { onConflict: User.email, onConflictUpdate: [User.name] }\n);\n\n// Composite unique key\nawait UserPref.create(\n  [[UserPref.user_id, 1], [UserPref.key, 'theme'], [UserPref.value, 'dark']],\n  { onConflict: [UserPref.user_id, UserPref.key], onConflictUpdate: [UserPref.value] }\n);\n```\n\n### Behavior Notes\n\n#### PkeyResult Semantics\n\n| Aspect | Behavior |\n|--------|----------|\n| **Order** | Matches database `RETURNING` order (not guaranteed across DBs; `findById(result)` order is also unspecified) |\n| **update result** | Contains PKs of **matched rows** (rows matching WHERE condition, regardless of whether values actually changed) |\n| **delete result** | Contains PKs of **deleted rows** |\n| **Duplicates** | No duplicates (each row appears once; MySQL pre-SELECT uses `DISTINCT`) |\n| **Empty result** | `{ key: [...], values: [] }` when no rows affected (not `null`) |\n\n\u003e **Note:** For MySQL (no `RETURNING`), when `returning: true`:\n\u003e - `update`/`delete`: Executes pre-SELECT (with `DISTINCT`) to get PKs, then executes the operation (2 queries in same transaction)\n\u003e - `updateMany`: Executes update, then SELECT to get PKs of affected rows (2 queries in same transaction)\n\u003e - When `returning: false` (default): Single query, returns `null`\n\n#### Batch Limits\n\n`createMany` and `updateMany` do **not** auto-split large batches. Users are responsible for chunking:\n\n```typescript\n// Recommended: chunk large batches (DB-dependent limits)\nconst BATCH_SIZE = 1000;  // Adjust based on your DB and row size\nfor (let i = 0; i \u003c rows.length; i += BATCH_SIZE) {\n  const chunk = rows.slice(i, i + BATCH_SIZE);\n  await User.updateMany(chunk, { keyColumns: [User.id] });\n}\n```\n\n| Database | Practical Limits |\n|----------|------------------|\n| PostgreSQL | ~32,767 parameters per query |\n| MySQL | `max_allowed_packet` (default 64MB), ~65,535 placeholders |\n| SQLite | 999 variables (compile-time `SQLITE_MAX_VARIABLE_NUMBER`) |\n\n#### updateMany keyColumns Contract\n\n| Requirement | Description |\n|-------------|-------------|\n| **Must be unique** | `keyColumns` must uniquely identify rows (primary key or unique constraint) |\n| **Must exist in rows** | Every row must include all `keyColumns` |\n| **Non-key columns** | Columns not in `keyColumns` become `SET` clause values |\n\n```typescript\n// ✅ Valid: keyColumns is primary key\nawait User.updateMany([\n  [[User.id, 1], [User.name, 'John']],\n], { keyColumns: [User.id] });\n\n// ✅ Valid: keyColumns is unique constraint\nawait User.updateMany([\n  [[User.email, 'john@example.com'], [User.name, 'John']],\n], { keyColumns: [User.email] });  // If email has UNIQUE constraint\n\n// ❌ Invalid: keyColumns missing from row\nawait User.updateMany([\n  [[User.name, 'John']],  // Missing User.id!\n], { keyColumns: [User.id] });\n```\n\n---\n\n## Type-Safe Conditions\n\n### Column Tuples (recommended for equality/simple conditions)\n\nThe simplest and most type-safe form. The value type is validated at compile time:\n\n```typescript\nawait User.find([\n  [User.status, 'active'],           // status = 'active'\n  [User.is_active, true],            // is_active = TRUE\n]);\nawait User.find([[User.email, 'john@example.com']]);\n```\n\n### `sql` Tagged Template (for operators, LIKE, BETWEEN, IS NULL, etc.)\n\nUse the `sql` tagged template for conditions that need operators. The `sql` tag extracts the Column reference, producing a type-safe fragment.\nSee [sql Tagged Template Literal](./docs/sql-tagged-template.md) for full reference.\n\n```typescript\nimport { sql } from 'litedbmodel';\n\nawait User.find([\n  [sql`${User.age} \u003e ?`, 18],                          // age \u003e 18\n  [sql`${User.age} BETWEEN ? AND ?`, [18, 65]],        // age BETWEEN 18 AND 65\n  [sql`${User.name} LIKE ?`, '%test%'],                 // name LIKE '%test%'\n  [sql`${User.status} IN (?)`, ['active', 'pending']],  // status IN ('active', 'pending')\n  sql`${User.deleted_at} IS NULL`,                       // deleted_at IS NULL (no value needed)\n]);\n```\n\nValues can also be embedded directly in the template. The `sql` tag automatically extracts them as parameterized values:\n\n```typescript\nawait User.find([\n  sql`${User.age} \u003e ${18}`,\n  sql`${User.name} LIKE ${'%test%'}`,\n  sql`${User.deleted_at} IS NULL`,\n]);\n```\n\n### OR Conditions and ORDER BY\n\n```typescript\nawait User.find([\n  [User.is_active, true],\n  User.or(\n    [[User.role, 'admin']],\n    [[User.role, 'moderator']],\n  ),\n], { order: User.created_at.desc() });\n```\n\n\u003e **ESLint Plugin:** Use `litedbmodel/eslint-plugin` to catch mistakes that TypeScript cannot:\n\u003e - Wrong model columns (e.g., `User.find([[Post.id, 1]])`)\n\u003e - Hardcoded column names instead of `${Model.column}`\n\u003e - Missing `declare` keyword for relation properties\n\n---\n\n## Subquery Conditions\n\nIN/NOT IN and EXISTS/NOT EXISTS subqueries with composite key support.\nKey pairs use the same format as relation decorators: `[[parentCol, targetCol], ...]`\n\n```typescript\nimport { parentRef } from 'litedbmodel';\n\n// IN subquery - key pairs: [[parentCol, targetCol]]\nawait User.find([\n  User.inSubquery([[User.id, Order.user_id]], [\n    [Order.status, 'paid']\n  ])\n]);\n// → WHERE users.id IN (SELECT orders.user_id FROM orders WHERE orders.status = 'paid')\n\n// Composite key IN subquery\nawait User.find([\n  User.inSubquery([\n    [User.id, Order.user_id],\n    [User.group_id, Order.group_id],\n  ], [[Order.status, 'active']])\n]);\n// → WHERE (users.id, users.group_id) IN (SELECT orders.user_id, orders.group_id FROM orders WHERE orders.status = 'active')\n\n// NOT IN subquery\nawait User.find([\n  User.notInSubquery([[User.id, BannedUser.user_id]])\n]);\n// → WHERE users.id NOT IN (SELECT banned_users.user_id FROM banned_users)\n\n// Correlated subquery with parentRef\nawait User.find([\n  User.inSubquery([[User.id, Order.user_id]], [\n    [Order.tenant_id, parentRef(User.tenant_id)],\n    [Order.status, 'completed']\n  ])\n]);\n// → WHERE users.id IN (SELECT orders.user_id FROM orders WHERE orders.tenant_id = users.tenant_id AND orders.status = 'completed')\n\n// EXISTS subquery (conditions determine target table)\nawait User.find([\n  [User.is_active, true],\n  User.exists([\n    [Order.user_id, parentRef(User.id)]\n  ])\n]);\n// → WHERE is_active = TRUE AND EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)\n\n// NOT EXISTS subquery\nawait User.find([\n  User.notExists([\n    [Complaint.user_id, parentRef(User.id)]\n  ])\n]);\n// → WHERE NOT EXISTS (SELECT 1 FROM complaints WHERE complaints.user_id = users.id)\n```\n\n---\n\n## Declarative SKIP Pattern\n\nConditional fields without if-statements:\n\n```typescript\nimport { SKIP } from 'litedbmodel';\n\n// ❌ Imperative\nconst updates = [];\nif (body.name !== undefined) updates.push([User.name, body.name]);\nif (body.email !== undefined) updates.push([User.email, body.email]);\nawait User.update([[User.id, id]], updates);\n\n// ✅ Declarative with SKIP\nawait User.update([[User.id, id]], [\n  [User.name, body.name ?? SKIP],\n  [User.email, body.email ?? SKIP],\n  [User.updated_at, new Date()],\n]);\n```\n\nWorks for conditions too:\n\n```typescript\nawait User.find([\n  [User.deleted, false],\n  query.name ? [sql`${User.name} LIKE ?`, `%${query.name}%`] : SKIP,\n  [User.status, query.status ?? SKIP],\n]);\n```\n\n**SKIP Behavior by Operation**:\n\n| Operation | SKIP Behavior |\n|-----------|---------------|\n| `find` / `findOne` / `count` | Condition excluded from WHERE |\n| `create` / `update` | Column excluded from INSERT/UPDATE |\n| `createMany` | Column excluded → DB DEFAULT applied |\n| `updateMany` | Column excluded → existing value retained |\n\n```typescript\n// createMany - SKIPped columns get DEFAULT value\nawait User.createMany([\n  [[User.name, 'John'], [User.email, 'john@test.com']],\n  [[User.name, 'Jane'], [User.email, SKIP]],  // email = DEFAULT\n]);\n\n// updateMany - SKIPped columns unchanged\nawait User.updateMany([\n  [[User.id, 1], [User.email, 'new@test.com'], [User.status, SKIP]],  // status unchanged\n  [[User.id, 2], [User.email, SKIP], [User.status, 'active']],       // email unchanged\n], { keyColumns: User.id });\n```\n\n**Batch SQL Strategy by Database**:\n\n| Database | createMany | updateMany |\n|----------|------------|------------|\n| PostgreSQL | Grouped `UNNEST` INSERT per SKIP pattern | `UNNEST` + `CASE WHEN skip_flag` |\n| MySQL | Grouped `VALUES ROW` INSERT per SKIP pattern | `JOIN VALUES ROW` + `IF(skip_flag)` |\n| SQLite | Grouped `VALUES` INSERT per SKIP pattern | `CASE WHEN key=? THEN col ELSE ?` |\n\nRecords with the same SKIP pattern are batched together for efficient INSERT. Each database uses native batch syntax while ensuring SKIPped columns receive DEFAULT values (createMany) or retain existing values (updateMany).\n\n\u003e **Note on `createMany` with SKIP:** `createMany` groups records by their SKIP pattern and issues a separate INSERT per group, because SQL does not allow mixing `DEFAULT` with array-based bulk insert (`UNNEST`). `updateMany` does not have this limitation — it handles SKIP in a single query using boolean flags. For best performance with `createMany`, prefer providing explicit values for all columns instead of using SKIP.\n\n---\n\n## Relation Decorators\n\nDefine relations declaratively with type-safe decorators:\n\n```typescript\nimport { DBModel, model, column, hasMany, belongsTo, hasOne } from 'litedbmodel';\n\n@model('users')\nclass UserModel extends DBModel {\n  @column() id?: number;\n  @column() name?: string;\n\n  // Use 'declare' for relation properties (not '!' assertion)\n  // This prevents TypeScript from creating instance properties that shadow the getter\n  @hasMany(() =\u003e [User.id, Post.author_id])\n  declare posts: Promise\u003cPost[]\u003e;\n\n  @hasOne(() =\u003e [User.id, UserProfile.user_id])\n  declare profile: Promise\u003cUserProfile | null\u003e;\n}\nexport const User = UserModel.asModel();\n\n@model('posts')\nclass PostModel extends DBModel {\n  @column() id?: number;\n  @column() author_id?: number;\n  @column() title?: string;\n\n  @belongsTo(() =\u003e [Post.author_id, User.id])\n  declare author: Promise\u003cUser | null\u003e;\n\n  @hasMany(() =\u003e [Post.id, Comment.post_id])\n  declare comments: Promise\u003cComment[]\u003e;\n}\nexport const Post = PostModel.asModel();\n\n// Usage\nconst post = await Post.findOne([[Post.id, 1]]);\nconst author = await post.author;       // Lazy loaded\nconst comments = await post.comments;   // Lazy loaded\n```\n\n\u003e **Important:** Use `declare` (not `!`) for relation properties. TypeScript class field declarations with `!` create instance properties that shadow the prototype getter. The ESLint plugin detects this mistake.\n\n### With Options (order, where, limit)\n\n```typescript\n@hasMany(() =\u003e [User.id, Post.author_id], {\n  order: () =\u003e Post.created_at.desc(),\n  where: () =\u003e [[Post.is_deleted, false]],\n})\ndeclare activePosts: Promise\u003cPost[]\u003e;\n\n// Per-parent limit - fetch only N records per parent key\n@hasMany(() =\u003e [User.id, Post.author_id], {\n  limit: 5,\n  order: () =\u003e Post.created_at.desc(),\n})\ndeclare recentPosts: Promise\u003cPost[]\u003e;  // Each user gets their 5 most recent posts\n```\n\nThe `limit` option applies SQL-level limiting **per parent key** during batch loading:\n- **PostgreSQL**: Uses `LATERAL JOIN` for efficient per-group limiting\n- **MySQL/SQLite**: Uses `ROW_NUMBER() OVER (PARTITION BY ...)` window function\n\nThis is more efficient than fetching all records and filtering in application code.\n\n\u003e **Important:** Always use `order` with `limit`. Without ordering, the \"which N records\" is non-deterministic and may vary between queries.\n\n### Composite Key Relations\n\n```typescript\n@model('tenant_posts')\nclass TenantPostModel extends DBModel {\n  @column({ primaryKey: true }) tenant_id?: number;\n  @column({ primaryKey: true }) id?: number;\n  @column() author_id?: number;\n\n  @belongsTo(() =\u003e [\n    [TenantPost.tenant_id, TenantUser.tenant_id],\n    [TenantPost.author_id, TenantUser.id],\n  ])\n  declare author: Promise\u003cTenantUser | null\u003e;\n}\n```\n\n### Transparent N+1 Prevention\n\nWhen `find()` returns multiple records, batch loading is **automatic** — no eager loading specification needed:\n\n```typescript\nconst users = await User.find([]);  // Auto batch context created\n\nfor (const user of users) {\n  const posts = await user.posts;   // First access batch loads ALL users' posts\n}\n// Total: 2 queries instead of N+1!\n```\n\nWrite natural code (`await user.posts`); litedbmodel handles the optimization.\n\n---\n\n## Query Limits (Safety Guards)\n\nPrevent accidental loading of too many records with configurable hardLimits:\n\n```typescript\n// Global configuration\nDBModel.setConfig(config, {\n  findHardLimit: 1000,       // find() throws if \u003e 1000 records\n  hasManyHardLimit: 10000,   // hasMany throws if \u003e 10000 records total (batch)\n});\n\n// Or update later\nDBModel.setLimitConfig({ findHardLimit: 500, hasManyHardLimit: 5000 });\n```\n\nWhen limits are exceeded, `LimitExceededError` is thrown:\n\n```typescript\nimport { LimitExceededError } from 'litedbmodel';\n\ntry {\n  const users = await User.find([]);  // May throw if too many records\n} catch (e) {\n  if (e instanceof LimitExceededError) {\n    console.log(`Limit ${e.limit} exceeded: got ${e.actualCount} records`);\n  }\n}\n```\n\n**Per-relation hardLimit override:**\n\nYou can override the global `hasManyHardLimit` for specific relations:\n\n```typescript\n@hasMany(() =\u003e [User.id, Post.author_id], {\n  hardLimit: 500,   // Override global hasManyHardLimit for this relation\n})\ndeclare posts: Promise\u003cPost[]\u003e;\n\n@hasMany(() =\u003e [User.id, Log.user_id], {\n  hardLimit: null,  // Disable limit check for this relation\n})\ndeclare logs: Promise\u003cLog[]\u003e;\n```\n\n\u003e **Note:** `findHardLimit` and `hasManyHardLimit` are safety guards implemented as `LIMIT N+1` at SQL level. If the result exceeds the limit, it throws immediately — this minimizes data transfer while detecting overflow. For explicit SQL-level limiting (e.g., \"N records per parent\"), use the `limit` option described in [With Options](#with-options-order-where-limit).\n\n---\n\n## Transactions\n\n```typescript\n// Basic\nawait DBModel.transaction(async () =\u003e {\n  const user = await User.findOne([[User.id, 1]]);\n  await Account.update([[Account.user_id, user.id]], [[Account.balance, 100]]);\n});\n\n// With return value\nconst user = await DBModel.transaction(async () =\u003e {\n  return await User.create([[User.name, 'Alice']]);\n});\n\n// Auto-retry on deadlock\nawait DBModel.transaction(\n  async () =\u003e { /* ... */ },\n  { retryOnError: true, retryLimit: 3 }\n);\n\n// Preview mode (rollback after execution)\nawait DBModel.transaction(\n  async () =\u003e { /* ... */ },\n  { rollbackOnly: true }\n);\n```\n\n---\n\n## Middleware\n\nClass-based middleware for cross-cutting concerns.\n\n### Call Flow\n\nAll database operations flow through the middleware system:\n\n```mermaid\nflowchart TD\n    subgraph \"High-Level API\"\n        find[\"find()\"]\n        findOne[\"findOne()\"]\n        findById[\"findById()\"]\n        count[\"count()\"]\n        create[\"create()\"]\n        createMany[\"createMany()\"]\n        update[\"update()\"]\n        updateMany[\"updateMany()\"]\n        delete[\"delete()\"]\n    end\n    \n    subgraph RelationAPI[\"Relation API\"]\n        belongsTo[\"@belongsTo\"]\n        hasMany[\"@hasMany\"]\n        hasOne[\"@hasOne\"]\n    end\n    \n    subgraph \"Middle-Level API\"\n        query[\"query()\"]\n    end\n    \n    subgraph \"Low-Level API\"\n        execute[\"execute()\"]\n    end\n    \n    subgraph \"Database\"\n        DB[(DB)]\n    end\n    \n    find --\u003e query\n    findOne --\u003e query\n    findById --\u003e execute\n    \n    belongsTo --\u003e query\n    hasMany --\u003e query\n    hasOne --\u003e query\n    \n    count --\u003e execute\n    create --\u003e execute\n    createMany --\u003e execute\n    update --\u003e execute\n    updateMany --\u003e execute\n    delete --\u003e execute\n    \n    query --\u003e execute\n    execute --\u003e DB\n    \n    style RelationAPI fill:#e0e0e0,stroke:#999\n```\n\n**Middleware hooks:**\n- **Method-level**: `find`, `findOne`, `findById`, `count`, `create`, `createMany`, `update`, `updateMany`, `delete`\n- **Instantiation-level**: `query` — returns model instances from raw SQL\n- **SQL-level**: `execute` — intercepts ALL SQL queries (SELECT, INSERT, UPDATE, DELETE)\n\n\u003e **Note:** Relation API (`@belongsTo`, `@hasMany`, `@hasOne`) bypasses method-level middleware hooks and calls `query()` directly. To intercept relation queries, use Instantiation-level (`query`) middleware.\n\n### Example\n\n```typescript\n// Simple logger (no state needed)\nconst LoggerMiddleware = DBModel.createMiddleware({\n  execute: async function(next, sql, params) {\n    console.log('SQL:', sql);\n    return next(sql, params);\n  }\n});\n\n// With per-request state (fully type-safe)\nconst TenantMiddleware = DBModel.createMiddleware({\n  // Initial state for each request (deep-cloned per request)\n  state: { tenantId: 0, queryCount: 0 },\n  \n  // Hook signature: (model, next, ...args) for method-level hooks\n  find: async function(model, next, conditions, options) {\n    // `this` is typed as { tenantId: number; queryCount: number }\n    this.queryCount++;\n    const tenantCol = (model as { tenant_id?: Column }).tenant_id;\n    if (tenantCol) {\n      conditions = [[tenantCol, this.tenantId], ...conditions];\n    }\n    return next(conditions, options);\n  }\n});\n\n// Register\nDBModel.use(LoggerMiddleware);\nDBModel.use(TenantMiddleware);\n\n// Per-request usage (type-safe)\nTenantMiddleware.getCurrentContext().tenantId = req.user.tenantId;\nconsole.log(TenantMiddleware.getCurrentContext().queryCount);\n```\n\n**State lifecycle:** Each HTTP request gets its own copy of the `state` object via `AsyncLocalStorage`. States are isolated between concurrent requests and automatically cleaned up when the request ends.\n\n---\n\n# Advanced Features\n\n## Raw SQL Methods\n\nWhen `find()` isn't enough, use real SQL directly. No query builder translation needed.\n\n\u003e **Portability note:** Tuple API (`find()`, `create()`, `update()`) and relation loading are DB-portable (config-only switching). Raw SQL via `query()` is your escape hatch for DB-specific optimizations—you control the dialect (placeholders, functions, type casts).\n\n### Model.query() — SQL with Type-Safe Results\n\nExecute any SQL and get typed model instances. The SQL you write is exactly what runs.\n\n```typescript\n// Complex JOIN with subquery - returns User[] with full type safety\nconst activeUsers = await User.query(`\n  SELECT u.* \n  FROM users u\n  INNER JOIN (\n    SELECT user_id, COUNT(*) as order_count\n    FROM orders\n    WHERE created_at \u003e= $1\n    GROUP BY user_id\n    HAVING COUNT(*) \u003e= $2\n  ) active ON u.id = active.user_id\n  WHERE u.status = 'active'\n  ORDER BY active.order_count DESC\n`, [lastMonth, minOrders]);\n\n// Window functions, CTEs, recursive queries - anything PostgreSQL supports\n@model('user_rankings')\nclass UserRankingModel extends DBModel {\n  @column() user_id?: number;\n  @column() score?: number;\n  @column() rank?: number;\n  @column() percentile?: number;\n}\nconst UserRanking = UserRankingModel.asModel();\n\nconst rankings = await UserRanking.query(`\n  WITH ranked AS (\n    SELECT \n      user_id,\n      score,\n      RANK() OVER (PARTITION BY category ORDER BY score DESC) as rank,\n      PERCENT_RANK() OVER (PARTITION BY category ORDER BY score) as percentile\n    FROM user_scores\n    WHERE created_at \u003e= $1\n  )\n  SELECT * FROM ranked WHERE rank \u003c= 100\n`, [startDate]);\n// rankings: UserRanking[] - full IDE autocomplete, type checking\n```\n\n### DBModel.execute() - Non-Model Operations\n\nUse `execute()` for DDL, maintenance, and operations that don't return model instances.\nAccepts either raw SQL strings or `sql` tagged templates:\n\n```typescript\nimport { sql } from 'litedbmodel';\n\n// With sql tag — parameters are co-located\nawait DBModel.execute(sql`SELECT process_daily_aggregates(${targetDate})`);\nawait DBModel.execute(sql`SELECT pg_notify(${'events'}, ${JSON.stringify(payload)})`);\n\n// Raw SQL strings for DDL and maintenance\nawait DBModel.execute('REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_sales_summary');\nawait DBModel.execute('VACUUM ANALYZE orders');\nawait DBModel.execute('CREATE INDEX CONCURRENTLY idx_orders_date ON orders(created_at)');\n```\n\n### When to Use Each Method\n\n| Method | Use Case | Returns |\n|--------|----------|---------|\n| `Model.find()` | Simple queries with conditions | `Model[]` |\n| `Model.query()` | Complex SQL returning model data | `Model[]` |\n| `DBModel.execute()` | DDL, maintenance, procedures | `{ rows, rowCount }` |\n| Query-Based Models | Reusable complex queries | `Model[]` via `find()` |\n\n---\n\n## Query-Based Models\n\nDefine models backed by complex SQL queries instead of simple tables.\nUse `find()`, `findOne()`, `count()` on JOINs, aggregations, CTEs, and analytics queries.\n\n### Basic Concept\n\n```typescript\nimport { DBModel, model, column } from 'litedbmodel';\n\n@model('user_stats')  // Alias for the CTE\nclass UserStatsModel extends DBModel {\n  @column() id?: number;\n  @column() name?: string;\n  @column() post_count?: number;\n  @column() comment_count?: number;\n  @column() last_activity?: Date;\n\n  // Define the base query\n  static QUERY = `\n    SELECT \n      u.id,\n      u.name,\n      COUNT(DISTINCT p.id) AS post_count,\n      COUNT(DISTINCT c.id) AS comment_count,\n      GREATEST(MAX(p.created_at), MAX(c.created_at)) AS last_activity\n    FROM users u\n    LEFT JOIN posts p ON u.id = p.user_id\n    LEFT JOIN comments c ON u.id = c.user_id\n    WHERE u.deleted_at IS NULL\n    GROUP BY u.id, u.name\n  `;\n}\nexport const UserStats = UserStatsModel.asModel();\n\n// Use find() with additional conditions\nconst topContributors = await UserStats.find([\n  [sql`${UserStats.post_count} \u003e= ?`, 10],\n  [sql`${UserStats.last_activity} \u003e ?`, lastWeek],\n], { order: UserStats.post_count.desc(), limit: 100 });\n```\n\n### Generated SQL (CTE-based)\n\nWhen `find()` is called, the QUERY becomes a CTE (WITH clause):\n\n```sql\nWITH user_stats AS (\n  SELECT \n    u.id,\n    u.name,\n    COUNT(DISTINCT p.id) AS post_count,\n    COUNT(DISTINCT c.id) AS comment_count,\n    GREATEST(MAX(p.created_at), MAX(c.created_at)) AS last_activity\n  FROM users u\n  LEFT JOIN posts p ON u.id = p.user_id\n  LEFT JOIN comments c ON u.id = c.user_id\n  WHERE u.deleted_at IS NULL\n  GROUP BY u.id, u.name\n)\nSELECT * FROM user_stats\nWHERE post_count \u003e= $1 AND last_activity \u003e $2\nORDER BY post_count DESC\nLIMIT 100\n```\n\n### Parameterized Queries with `sql` Tag\n\nFor queries that need runtime parameters, use the `sql` tagged template with `withQuery()`. Parameters are co-located with SQL — no manual `$1`/`$2` numbering or DB dialect differences:\n\n```typescript\nimport { sql } from 'litedbmodel';\n\n@model('sales_report')\nclass SalesReportModel extends DBModel {\n  @column() product_id?: number;\n  @column() product_name?: string;\n  @column() total_quantity?: number;\n  @column() total_revenue?: number;\n  @column() order_count?: number;\n\n  static forPeriod(startDate: string, endDate: string) {\n    return this.withQuery(sql`\n      SELECT \n        p.id AS product_id,\n        p.name AS product_name,\n        SUM(oi.quantity) AS total_quantity,\n        SUM(oi.quantity * oi.unit_price) AS total_revenue,\n        COUNT(DISTINCT o.id) AS order_count\n      FROM products p\n      INNER JOIN order_items oi ON p.id = oi.product_id\n      INNER JOIN orders o ON oi.order_id = o.id\n      WHERE o.status = 'completed'\n        AND o.created_at \u003e= ${startDate} \n        AND o.created_at \u003c ${endDate}\n      GROUP BY p.id, p.name\n    `);\n  }\n}\nexport const SalesReport = SalesReportModel.asModel();\n\n// Usage\nconst Q1Report = SalesReport.forPeriod('2024-01-01', '2024-04-01');\nconst topProducts = await Q1Report.find([\n  [sql`${SalesReport.total_revenue} \u003e ?`, 10000],\n], { order: SalesReport.total_revenue.desc() });\n```\n\nThe `sql` tag automatically converts interpolated values to `?` placeholders and extracts them as parameters. The internal dialect conversion (`?` → `$1`/`$2` for PostgreSQL) is handled transparently.\n\n### Type-Safe Column References in QUERY\n\nUse the `sql` tag with Column references and **Model classes** for refactoring safety. Columns and table names are embedded directly in SQL; they are not parameterized:\n\n```typescript\nimport { sql } from 'litedbmodel';\n\n@model('user_activity')\nclass UserActivityModel extends DBModel {\n  @column() user_id?: number;\n  @column() user_name?: string;\n  @column() total_posts?: number;\n\n  static QUERY = sql`\n    SELECT \n      ${User.id} AS user_id,\n      ${User.name} AS user_name,\n      COUNT(${Post.id}) AS total_posts\n    FROM ${User}\n    LEFT JOIN ${Post} ON ${User.id} = ${Post.user_id}\n    GROUP BY ${User.id}, ${User.name}\n  `;\n}\n```\n\nPassing a Model class (`${User}`) in the `sql` tag expands to its `TABLE_NAME`. This is consistent with column references (`${User.id}` → column name).\n\n### Use Cases\n\n| Use Case | Example |\n|----------|---------|\n| **Aggregations** | User stats, sales reports, leaderboards |\n| **Analytics** | Cohort analysis, funnel metrics, trend data |\n| **Denormalized Views** | Pre-joined data for read-heavy operations |\n| **Time-Series** | Period-based summaries with window functions |\n| **Recursive Queries** | Organizational hierarchies, category trees |\n\n### Design Considerations\n\n1. **Read-Only**: Query-based models don't support `create()`, `update()`, `delete()`\n2. **CTE vs Subquery**: CTE approach produces cleaner, more readable SQL\n3. **Parameter Ordering**: QUERY params come first, then `find()` condition params\n4. **Caching**: Consider materializing frequently-used query models as actual views\n\n---\n\n## Reader/Writer Separation\n\nFor production deployments with read replicas, litedbmodel supports automatic connection routing.\n\n### Configuration\n\n```typescript\nDBModel.setConfig(\n  { host: 'reader.db.example.com', database: 'mydb', ... },  // reader (default)\n  {\n    writerConfig: { host: 'writer.db.example.com', database: 'mydb', ... },\n    \n    // Keep using writer after transaction (default: true)\n    // Avoids stale reads due to replication lag\n    useWriterAfterTransaction: true,\n    \n    // Duration to keep using writer after transaction (ms, default: 5000)\n    writerStickyDuration: 5000,\n  }\n);\n```\n\n### Connection Routing Rules\n\n| Context | Connection | Write Allowed |\n|---------|------------|---------------|\n| Inside `transaction()` | Writer | ✅ Yes |\n| Inside `withWriter()` | Writer | ❌ No (SELECT only) |\n| After transaction (within sticky duration) | Writer | ❌ No |\n| Normal query | Reader | ❌ No |\n\n**Important:** Write operations (`create()`, `update()`, `delete()`) require a transaction. Attempting to write outside a transaction throws an error.\n\n### Transaction Options\n\n```typescript\n// Override global useWriterAfterTransaction per transaction\nawait DBModel.transaction(\n  async () =\u003e {\n    await User.create([[User.name, 'John']]);\n  },\n  { \n    useWriterAfterTransaction: false,  // Don't stick to writer after this transaction\n  }\n);\n```\n\n### Explicit Writer Access (SELECT)\n\nUse `withWriter()` when you need to read from writer to avoid replication lag:\n\n```typescript\n// Read from writer explicitly\nconst user = await DBModel.withWriter(async () =\u003e {\n  return await User.findOne([[User.id, 1]]);\n});\n\n// Write inside withWriter() throws error - use transaction() instead\nawait DBModel.withWriter(async () =\u003e {\n  await User.create([[User.name, 'Error']]);  // → WriteInReadOnlyContextError\n});\n```\n\n---\n\n## Multi-Database Support\n\nFor applications connecting to multiple databases, use `createDBBase()` to create isolated base classes.\n\n### Setup\n\n```typescript\nimport { DBModel, model, column } from 'litedbmodel';\n\n// Foundation database\nconst BaseDB = DBModel.createDBBase({\n  host: 'base-reader.example.com',\n  database: 'base_db',\n  // ...\n}, {\n  writerConfig: { host: 'base-writer.example.com', database: 'base_db', ... },\n});\n\n// CMS database\nconst CmsDB = DBModel.createDBBase({\n  host: 'cms-reader.example.com',\n  database: 'cms_db',\n  // ...\n}, {\n  writerConfig: { host: 'cms-writer.example.com', database: 'cms_db', ... },\n});\n```\n\n### Model Definition\n\n```typescript\n// Models inherit from their respective database base class\n@model('users')\nclass UserModel extends BaseDB {\n  @column() id?: number;\n  @column() name?: string;\n}\nexport const User = UserModel.asModel();\n\n@model('articles')\nclass ArticleModel extends CmsDB {\n  @column() id?: number;\n  @column() title?: string;\n}\nexport const Article = ArticleModel.asModel();\n```\n\n### Independent Transactions\n\nEach database has its own transaction context:\n\n```typescript\n// BaseDB transaction\nawait BaseDB.transaction(async () =\u003e {\n  await User.create([[User.name, 'John']]);\n});\n\n// CmsDB transaction (independent)\nawait CmsDB.transaction(async () =\u003e {\n  await Article.create([[Article.title, 'Hello World']]);\n});\n\n// Each DB also has independent withWriter()\nconst article = await CmsDB.withWriter(async () =\u003e {\n  return await Article.findOne([[Article.id, 1]]);\n});\n```\n\n### Scope Isolation\n\n| Resource | Scope | Description |\n|----------|-------|-------------|\n| Connection Handler | Per DBBase | Each base class has its own connection pool |\n| Transaction Context | Per DBBase | `AsyncLocalStorage` isolated per base class |\n| Writer Context | Per DBBase | `withWriter()` isolated per base class |\n| Sticky Timer | Per DBBase | Writer sticky duration tracked separately |\n| Middlewares | **Global** | Cross-cutting concerns shared across all DBs |\n| Model Registry | **Global** | For relation resolution across databases |\n\n---\n\n# APPENDIX\n\n## Comparison\n\n| Feature | litedbmodel | Kysely | Drizzle | TypeORM | Prisma |\n|---------|-------------|--------|---------|---------|--------|\n| **Relation Loading** | On-demand | Manual | Eager/upfront | Eager/upfront | Include |\n| **Complex Queries** | ✅ Real SQL | Builder DSL | Builder DSL | HQL/Builder | Prisma DSL |\n| **Query-Based Models** | ✅ | ❌ | ❌ | Views only | Views only |\n| **Model-Centric Relations** | ✅ On-demand | ❌ | ❌ Eager | ❌ Eager | ❌ Include |\n| **Transparent N+1 Prevention** | ✅ | ❌ Manual | ⚠️ { with } | Eager only | Include |\n| **IDE Refactoring** | ✅ | ❌ | ⚠️ Partial | ❌ | ❌ |\n| **SKIP Pattern** | ✅ | ❌ | ❌ | ❌ | ❌ |\n| **Extensibility** | Middleware | Plugins | ❌ Manual | Subscribers | Extensions |\n| **Performance** | 🏆 Fastest (9/19 wins) | Fast | Fast | Medium | Slow |\n\n\u003e See [COMPARISON.md](./docs/COMPARISON.md) for detailed analysis and [BENCHMARK.md](./docs/BENCHMARK.md) for benchmarks.\n\n---\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffoo-ogawa%2Flitedbmodel.ts","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffoo-ogawa%2Flitedbmodel.ts","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffoo-ogawa%2Flitedbmodel.ts/lists"}