Claude Skill

api-database-typeorm

Decorator-based ORM for TypeScript with Active Record and Data Mapper patterns

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_api-database-typeorm_skills_api-database-typeorm-3a51ef5.zip · 31 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-database-typeorm/skills/api-database-typeorm
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git git clone https://github.com/agents-inc/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Database with TypeORM

Quick Guide: Use TypeORM for decorator-based database access with full TypeScript support. Schema defined via entity classes with @Entity, @Column, @PrimaryGeneratedColumn. Use Data Mapper pattern (repositories) over Active Record for non-trivial apps. Never use synchronize: true in production - use migrations. Prefer insert()/update() over save() when you know the operation type - save() always executes a SELECT first. Use QueryRunner transactions for full control. Eager relations only work with find* methods, not QueryBuilder.


<critical_requirements>

CRITICAL: Before Using This Skill

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST NEVER use synchronize: true in production - it can drop columns and lose data when entities change)

(You MUST use insert()/update() instead of save() when the operation type is known - save() always runs an extra SELECT query)

(You MUST use the provided transaction manager parameter or queryRunner.manager inside transactions - NEVER use the global entity manager or repository)

(You MUST define relations with explicit @JoinColumn() on the owning side of @OneToOne and optionally @ManyToOne, and @JoinTable() on one side of @ManyToMany)

</critical_requirements>


Auto-detection: typeorm, TypeORM, DataSource, @Entity, @Column, @PrimaryGeneratedColumn, @ManyToOne, @OneToMany, @ManyToMany, createQueryBuilder, getRepository, EntityManager, QueryRunner, migration:generate, migration:run

When to use:

  • Decorator-based entity definitions with TypeScript
  • Applications requiring both Active Record and Data Mapper patterns
  • Complex queries needing QueryBuilder with joins and subqueries
  • Projects where class-based ORM feels natural (especially with DI-based frameworks)

When NOT to use:

  • Schema-first workflows (consider schema-first ORMs instead)
  • Needing fully type-safe queries without runtime decorators (consider lighter ORMs)
  • Edge/serverless with minimal cold start (decorator metadata adds weight)
  • Projects avoiding reflect-metadata and experimentalDecorators

Key patterns covered:

  • DataSource configuration and entity registration
  • Entity definitions with decorators and column types
  • Relations (OneToOne, OneToMany, ManyToOne, ManyToMany)
  • Repository CRUD and QueryBuilder
  • Migrations (generate, run, revert)
  • Transactions (EntityManager callback, QueryRunner manual)
  • save() vs insert()/update() performance

Detailed Resources:




<red_flags>

RED FLAGS

High Priority Issues:

  • synchronize: true in production - alters schema on startup, can drop columns and lose data
  • Using save() for all writes - always runs SELECT first, 2x round trips for known inserts/updates
  • String interpolation in QueryBuilder .where() - SQL injection vulnerability
  • Using global entity manager inside transactions - bypasses transaction context
  • Missing queryRunner.release() in finally block - leaks database connections

Medium Priority Issues:

  • No indexes on frequently filtered columns - slow queries as data grows
  • Missing onDelete cascade on relations - orphaned rows when parent deleted
  • Using eager: true on both sides of a relation - TypeORM disallows this, throws error
  • Glob patterns for entity paths ("./src/**/*.entity.ts") - breaks with bundlers
  • Initializing relation arrays with = [] - causes TypeORM to detach all existing relations on save

Common Mistakes:

  • Expecting eager relations to work with QueryBuilder - eager only works with find* methods, use leftJoinAndSelect instead
  • Using @BeforeUpdate/@AfterUpdate with update() - listeners only fire with save(), not update()/insert()
  • Forgetting reflect-metadata import at app entry point - decorators silently fail
  • Using limit()/offset() with joins in QueryBuilder - returns wrong results; use take()/skip() instead
  • Not exposing FK column (e.g., authorId) alongside relation - forces a join for simple lookups

Gotchas & Edge Cases:

  • save() returns the saved entity but reloads it from DB - the returned object may differ from input
  • update() and delete() return UpdateResult/DeleteResult with affected count, not the entity
  • findOne({ where: {} }) with empty where returns the first row, not null - always provide conditions
  • Enum changes in entity require a migration - database enum types don't auto-update
  • @Column({ select: false }) excludes column from default SELECTs - must explicitly select with QueryBuilder
  • Lazy relations require Promise<T> type on the property - not intuitive for JS/TS developers
  • cascade: true can save unintended nested objects - be explicit with cascade: ["insert"] or cascade: ["update"]
  • Transaction isolation varies by database driver - not all levels available on all databases
  • QueryRunner must be released even on success - failure to release leaks connections until pool exhaustion

</red_flags>


<critical_reminders>

CRITICAL REMINDERS

All code must follow project conventions in CLAUDE.md

(You MUST NEVER use synchronize: true in production - it can drop columns and lose data when entities change)

(You MUST use insert()/update() instead of save() when the operation type is known - save() always runs an extra SELECT query)

(You MUST use the provided transaction manager parameter or queryRunner.manager inside transactions - NEVER use the global entity manager or repository)

(You MUST define relations with explicit @JoinColumn() on the owning side of @OneToOne and optionally @ManyToOne, and @JoinTable() on one side of @ManyToMany)

Failure to follow these rules will cause data loss from schema sync, doubled query counts from unnecessary SELECTs, broken transaction atomicity, and connection pool exhaustion.

</critical_reminders>

Files (skills)
  • examples
    • advanced.md 11.3 KB
      # TypeORM - Advanced Examples
      
      > Subscribers, listeners, tree entities, embedded entities. See [SKILL.md](../SKILL.md) for core concepts.
      
      **Prerequisites**: Understand entity definitions and DataSource from [core.md](core.md).
      
      ---
      
      ## Entity Listeners
      
      ### Good Example - Lifecycle Hooks on Entity
      
      ```typescript
      import {
        Entity,
        PrimaryGeneratedColumn,
        Column,
        BeforeInsert,
        BeforeUpdate,
        AfterLoad,
      } from "typeorm";
      import { createHash } from "crypto";
      
      @Entity("users")
      export class User {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column()
        email: string;
      
        @Column()
        name: string;
      
        @Column({ nullable: true })
        normalizedEmail: string | null;
      
        tempFullName: string; // Not a column - computed on load
      
        @BeforeInsert()
        normalizeEmailOnInsert() {
          this.normalizedEmail = this.email.toLowerCase().trim();
        }
      
        @BeforeUpdate()
        normalizeEmailOnUpdate() {
          if (this.email) {
            this.normalizedEmail = this.email.toLowerCase().trim();
          }
        }
      
        @AfterLoad()
        computeFullName() {
          this.tempFullName = this.name; // Compute derived properties on load
        }
      }
      ```
      
      **Why good:** Listeners keep entity logic self-contained, `@BeforeInsert`/`@BeforeUpdate` for data normalization, `@AfterLoad` for computed properties
      
      **Critical caveat:** `@BeforeUpdate` and `@AfterUpdate` only fire when using `save()`, NOT with `update()` or `insert()`. If you use `update()` (recommended for performance), listeners won't trigger.
      
      ### Available Listener Decorators
      
      | Decorator           | Fires When                  | Works With            |
      | ------------------- | --------------------------- | --------------------- |
      | `@BeforeInsert`     | Before entity inserted      | `save()` (new)        |
      | `@AfterInsert`      | After entity inserted       | `save()` (new)        |
      | `@BeforeUpdate`     | Before entity updated       | `save()` (existing)   |
      | `@AfterUpdate`      | After entity updated        | `save()` (existing)   |
      | `@BeforeRemove`     | Before entity removed       | `remove()`            |
      | `@AfterRemove`      | After entity removed        | `remove()`            |
      | `@BeforeSoftRemove` | Before soft delete          | `softRemove()`        |
      | `@AfterSoftRemove`  | After soft delete           | `softRemove()`        |
      | `@AfterLoad`        | After entity loaded from DB | `find*`, QueryBuilder |
      
      **Important:** Do NOT make database calls inside entity listeners. Use subscribers instead.
      
      ---
      
      ## Subscribers
      
      ### Good Example - Audit Log Subscriber
      
      ```typescript
      import {
        EventSubscriber,
        EntitySubscriberInterface,
        InsertEvent,
        UpdateEvent,
        RemoveEvent,
      } from "typeorm";
      import { User } from "../entities/user.entity";
      
      @EventSubscriber()
      export class UserSubscriber implements EntitySubscriberInterface<User> {
        // Listen only to User entity events
        listenTo() {
          return User;
        }
      
        async afterInsert(event: InsertEvent<User>): Promise<void> {
          await event.manager.insert("audit_logs", {
            action: "user_created",
            entityId: event.entity.id,
            data: JSON.stringify({ email: event.entity.email }),
            createdAt: new Date(),
          });
        }
      
        async afterUpdate(event: UpdateEvent<User>): Promise<void> {
          if (!event.entity) return; // entity may be undefined for bulk updates
          await event.manager.insert("audit_logs", {
            action: "user_updated",
            entityId: event.entity.id,
            data: JSON.stringify(event.updatedColumns.map((c) => c.propertyName)),
            createdAt: new Date(),
          });
        }
      
        async afterRemove(event: RemoveEvent<User>): Promise<void> {
          if (!event.entityId) return;
          await event.manager.insert("audit_logs", {
            action: "user_deleted",
            entityId: event.entityId,
            createdAt: new Date(),
          });
        }
      }
      ```
      
      **Why good:** Subscribers can make DB calls (unlike listeners), `event.manager` participates in the same transaction, `listenTo()` scopes to specific entity, null checks for bulk operations where entity may be undefined
      
      **Registration:** Add subscriber to DataSource config:
      
      ```typescript
      export const AppDataSource = new DataSource({
        // ...
        subscribers: [UserSubscriber],
      });
      ```
      
      ### Good Example - Global Subscriber (All Entities)
      
      ```typescript
      @EventSubscriber()
      export class TimestampSubscriber implements EntitySubscriberInterface {
        // No listenTo() = listens to ALL entities
      
        beforeInsert(event: InsertEvent<any>): void {
          // Set createdAt/updatedAt on any entity that has these properties
          if ("createdAt" in event.entity) {
            event.entity.createdAt = new Date();
          }
          if ("updatedAt" in event.entity) {
            event.entity.updatedAt = new Date();
          }
        }
      }
      ```
      
      **Why good:** Global subscribers apply cross-cutting logic to all entities without modifying each entity
      
      ---
      
      ## Embedded Entities
      
      ### Good Example - Reusable Column Groups
      
      ```typescript
      import { Column } from "typeorm";
      
      // Embeddable - not an @Entity, just a column group
      export class Address {
        @Column({ length: 255 })
        street: string;
      
        @Column({ length: 100 })
        city: string;
      
        @Column({ length: 10 })
        postalCode: string;
      
        @Column({ length: 100 })
        country: string;
      }
      
      @Entity("companies")
      export class Company {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column()
        name: string;
      
        // Embeds Address columns with prefix
        @Column(() => Address, { prefix: "billing" })
        billingAddress: Address;
        // Creates: billing_street, billing_city, billing_postalCode, billing_country
      
        @Column(() => Address, { prefix: "shipping" })
        shippingAddress: Address;
        // Creates: shipping_street, shipping_city, shipping_postalCode, shipping_country
      }
      ```
      
      **Why good:** Reusable column groups without extra tables, prefix prevents column name collisions, same `Address` structure used for billing and shipping
      
      **Usage:**
      
      ```typescript
      const company = new Company();
      company.name = "Acme";
      company.billingAddress = new Address();
      company.billingAddress.street = "123 Main St";
      company.billingAddress.city = "Springfield";
      // ...
      await companyRepo.save(company);
      ```
      
      ---
      
      ## Tree Entities
      
      ### Good Example - Closure Table (Best for Read and Write)
      
      ```typescript
      import {
        Entity,
        PrimaryGeneratedColumn,
        Column,
        Tree,
        TreeChildren,
        TreeParent,
      } from "typeorm";
      
      @Entity("categories")
      @Tree("closure-table") // Stores parent-child in separate closure table
      export class Category {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column()
        name: string;
      
        @TreeChildren()
        children: Category[];
      
        @TreeParent()
        parent: Category | null;
      }
      ```
      
      **Why good:** Closure table is efficient for both reads and writes, TypeORM manages the closure table automatically
      
      **Usage with TreeRepository:**
      
      ```typescript
      const categoryRepo = AppDataSource.getTreeRepository(Category);
      
      // Get full tree
      const trees = await categoryRepo.findTrees();
      
      // Get ancestors of a node
      const ancestors = await categoryRepo.findAncestors(category);
      
      // Get descendants of a node
      const descendants = await categoryRepo.findDescendants(category);
      
      // Get roots (no parent)
      const roots = await categoryRepo.findRoots();
      
      // Count descendants
      const count = await categoryRepo.countDescendants(category);
      ```
      
      ### Tree Strategy Comparison
      
      | Strategy          | Decorator                     | Read Speed       | Write Speed | Multiple Roots |
      | ----------------- | ----------------------------- | ---------------- | ----------- | -------------- |
      | Adjacency List    | Self-referencing `@ManyToOne` | Slow (recursive) | Fast        | Yes            |
      | Closure Table     | `@Tree("closure-table")`      | Fast             | Medium      | Yes            |
      | Materialized Path | `@Tree("materialized-path")`  | Fast             | Medium      | Yes            |
      | Nested Set        | `@Tree("nested-set")`         | Very Fast        | Slow        | No             |
      
      **Recommendation:** Use Closure Table for general-purpose trees. Use Materialized Path when simplicity matters. Avoid Nested Set unless reads vastly outnumber writes.
      
      ---
      
      ## Column with select: false
      
      ### Good Example - Sensitive Data Exclusion
      
      ```typescript
      @Entity("users")
      export class User {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column()
        email: string;
      
        @Column({ select: false }) // Excluded from default SELECTs
        passwordHash: string;
      
        @Column({ select: false })
        twoFactorSecret: string | null;
      }
      
      // Default find - passwordHash NOT included
      const user = await userRepo.findOneBy({ id: userId });
      // user.passwordHash is undefined
      
      // Explicitly select hidden column when needed
      const userWithPassword = await userRepo
        .createQueryBuilder("user")
        .addSelect("user.passwordHash")
        .where("user.id = :id", { id: userId })
        .getOne();
      // user.passwordHash is now available
      ```
      
      **Why good:** Sensitive columns excluded by default, must be explicitly requested, prevents accidental exposure in API responses
      
      ---
      
      ## Virtual/Computed Columns
      
      ### Good Example - Using @VirtualColumn (v0.3.11+)
      
      ```typescript
      @Entity("users")
      export class User {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column()
        firstName: string;
      
        @Column()
        lastName: string;
      
        @VirtualColumn({
          query: (alias) =>
            `SELECT COUNT(*) FROM "posts" WHERE "posts"."author_id" = ${alias}.id`,
        })
        postCount: number;
      }
      
      // postCount computed by DB on every query
      const user = await userRepo.findOneBy({ id: userId });
      // user.postCount is a number computed from the subquery
      ```
      
      **Why good:** DB-computed column, no application-level calculation, always up-to-date, available on standard find queries
      
      ---
      
      ## Custom Repository (Data Mapper Pattern)
      
      ### Good Example - Encapsulated Query Logic
      
      ```typescript
      // user.repository.ts
      import { AppDataSource } from "../data-source";
      import { User, UserRole } from "../entities/user.entity";
      
      const DEFAULT_PAGE_SIZE = 20;
      
      export const UserRepository = AppDataSource.getRepository(User).extend({
        findByEmail(email: string) {
          return this.findOneBy({ email });
        },
      
        findActiveAdmins() {
          return this.find({
            where: { role: UserRole.ADMIN, isActive: true },
            order: { name: "ASC" },
          });
        },
      
        async findPaginated(page: number, pageSize = DEFAULT_PAGE_SIZE) {
          return this.findAndCount({
            order: { createdAt: "DESC" },
            take: pageSize,
            skip: (page - 1) * pageSize,
          });
        },
      
        findWithPosts(userId: string) {
          return this.findOne({
            where: { id: userId },
            relations: { posts: true },
          });
        },
      });
      ```
      
      **Why good:** Query logic encapsulated in repository, `extend()` adds custom methods to standard repository, named constants for defaults, reusable across the application
      
      **Usage:**
      
      ```typescript
      const user = await UserRepository.findByEmail("alice@example.com");
      const admins = await UserRepository.findActiveAdmins();
      const [users, total] = await UserRepository.findPaginated(1);
      ```
      
      ---
      
      ## Quick Reference
      
      | Feature           | Approach              | Use When                                 |
      | ----------------- | --------------------- | ---------------------------------------- |
      | Entity Listeners  | Decorators on entity  | Simple sync logic (normalize, validate)  |
      | Subscribers       | Separate class        | Async logic, DB calls, cross-cutting     |
      | Embedded Entities | `@Column(() => Type)` | Reusable column groups without join      |
      | Tree Entities     | `@Tree("strategy")`   | Hierarchical data (categories, comments) |
      | Custom Repository | `repo.extend({})`     | Encapsulated query logic (Data Mapper)   |
      | Virtual Columns   | `@VirtualColumn`      | DB-computed values (counts, aggregates)  |
      
    • core.md 11.1 KB
      # TypeORM - Core Examples
      
      > DataSource setup, entity definitions, CRUD operations, and repository patterns. See [SKILL.md](../SKILL.md) for decision guidance.
      
      **Prerequisites**: None - these are the foundational patterns.
      
      ---
      
      ## DataSource Initialization
      
      ### Good Example - Async Initialization with Graceful Shutdown
      
      ```typescript
      // main.ts
      import "reflect-metadata"; // MUST be first import
      import { AppDataSource } from "./data-source";
      
      const bootstrap = async () => {
        try {
          await AppDataSource.initialize();
          console.log("DataSource initialized");
      
          // Start your server here...
        } catch (error) {
          console.error("DataSource initialization failed:", error);
          process.exit(1);
        }
      };
      
      // Graceful shutdown
      const shutdown = async () => {
        if (AppDataSource.isInitialized) {
          await AppDataSource.destroy();
          console.log("DataSource destroyed");
        }
        process.exit(0);
      };
      
      process.on("SIGTERM", shutdown);
      process.on("SIGINT", shutdown);
      
      bootstrap();
      ```
      
      **Why good:** `reflect-metadata` imported first (required for decorators), error handling on init, graceful shutdown prevents connection leaks, `isInitialized` check prevents double-destroy
      
      ### Bad Example - No Error Handling, No Shutdown
      
      ```typescript
      // BAD
      import { AppDataSource } from "./data-source";
      AppDataSource.initialize(); // Unhandled promise, no shutdown
      ```
      
      **Why bad:** Unhandled promise rejection crashes silently, leaked connections on process exit
      
      ---
      
      ## DataSource Configuration Variants
      
      ### Good Example - Environment-Aware Config
      
      ```typescript
      // data-source.ts
      import { DataSource } from "typeorm";
      import type { DataSourceOptions } from "typeorm";
      import { User } from "./entities/user.entity";
      import { Post } from "./entities/post.entity";
      
      const BASE_POOL_SIZE = 10;
      const PRODUCTION_POOL_SIZE = 25;
      
      const baseOptions: DataSourceOptions = {
        type: "postgres",
        host: process.env.DB_HOST ?? "localhost",
        port: Number(process.env.DB_PORT ?? 5432),
        username: process.env.DB_USER ?? "postgres",
        password: process.env.DB_PASS ?? "postgres",
        database: process.env.DB_NAME ?? "myapp",
        entities: [User, Post],
        migrations: ["./src/migrations/*.ts"],
        synchronize: false,
        logging:
          process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
        extra: {
          max:
            process.env.NODE_ENV === "production"
              ? PRODUCTION_POOL_SIZE
              : BASE_POOL_SIZE,
        },
      };
      
      export const AppDataSource = new DataSource(baseOptions);
      ```
      
      **Why good:** Named constants for pool sizes, explicit entity imports (no globs), conditional logging, `synchronize: false` always, pool size tuned per environment
      
      ---
      
      ## Entity Definitions
      
      ### Good Example - Complete Entity with All Common Patterns
      
      ```typescript
      import {
        Entity,
        PrimaryGeneratedColumn,
        Column,
        CreateDateColumn,
        UpdateDateColumn,
        DeleteDateColumn,
        Index,
        BeforeInsert,
      } from "typeorm";
      import { randomUUID } from "crypto";
      
      @Entity("users")
      export class User {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column({ unique: true, length: 255 })
        email: string;
      
        @Column({ length: 100 })
        name: string;
      
        @Column({
          type: "enum",
          enum: ["user", "admin", "moderator"],
          default: "user",
        })
        role: string;
      
        @Column({ type: "text", nullable: true })
        bio: string | null;
      
        @Column({ select: false }) // Excluded from default SELECTs
        passwordHash: string;
      
        @Column({ type: "boolean", default: true })
        isActive: boolean;
      
        @CreateDateColumn()
        createdAt: Date;
      
        @UpdateDateColumn()
        updatedAt: Date;
      
        @DeleteDateColumn() // Enables soft delete
        deletedAt: Date | null;
      }
      ```
      
      **Why good:** `select: false` on sensitive columns, `DeleteDateColumn` for soft delete, nullable types match TypeScript, explicit column types and lengths, `uuid` primary key
      
      ### Good Example - Enum as TypeScript Enum
      
      ```typescript
      export enum UserRole {
        USER = "user",
        ADMIN = "admin",
        MODERATOR = "moderator",
      }
      
      @Entity("users")
      export class User {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column({ type: "enum", enum: UserRole, default: UserRole.USER })
        role: UserRole;
      }
      ```
      
      **Why good:** TypeScript enum gives type safety and autocomplete, matches DB enum values
      
      ---
      
      ## Read Operations
      
      ### Good Example - Find Variants
      
      ```typescript
      const userRepo = AppDataSource.getRepository(User);
      
      // Find by primary key
      const user = await userRepo.findOneBy({ id: userId });
      // Returns: User | null
      
      // Find with conditions
      const admin = await userRepo.findOne({
        where: { email: "admin@example.com", role: UserRole.ADMIN },
      });
      
      // Find or throw
      const userOrThrow = await userRepo.findOneByOrFail({ id: userId });
      // Throws EntityNotFoundError if not found
      
      // Find many with options
      const DEFAULT_PAGE_SIZE = 20;
      const users = await userRepo.find({
        where: { isActive: true },
        order: { createdAt: "DESC" },
        take: DEFAULT_PAGE_SIZE,
        skip: 0,
        select: { id: true, name: true, email: true },
      });
      
      // Count
      const activeCount = await userRepo.countBy({ isActive: true });
      
      // Check existence (v0.3.12+)
      const exists = await userRepo.existsBy({ email: "alice@example.com" });
      ```
      
      **Why good:** `findOneBy` for simple lookups, `findOneByOrFail` when record must exist, `select` reduces payload, named constant for page size
      
      ### Good Example - Advanced Filtering with find()
      
      ```typescript
      import { In, Like, Between, IsNull, Not, LessThan, MoreThan } from "typeorm";
      
      // Multiple conditions (AND)
      const users = await userRepo.find({
        where: {
          role: In([UserRole.ADMIN, UserRole.MODERATOR]),
          isActive: true,
          createdAt: MoreThan(new Date("2024-01-01")),
        },
      });
      
      // OR conditions (array of where objects)
      const users = await userRepo.find({
        where: [{ email: Like("%@company.com") }, { role: UserRole.ADMIN }],
      });
      
      // Null checks
      const usersWithBio = await userRepo.find({
        where: { bio: Not(IsNull()) },
      });
      
      // Range
      const recentUsers = await userRepo.find({
        where: {
          createdAt: Between(startDate, endDate),
        },
      });
      ```
      
      **Why good:** TypeORM operators (`In`, `Like`, `Between`, etc.) are type-safe, array of `where` objects for OR conditions
      
      ---
      
      ## Write Operations
      
      ### Good Example - insert() vs save()
      
      ```typescript
      const userRepo = AppDataSource.getRepository(User);
      
      // PREFERRED: insert() for new records - single INSERT query
      const result = await userRepo.insert({
        email: "alice@example.com",
        name: "Alice",
        role: UserRole.USER,
      });
      // result.identifiers[0].id contains the generated ID
      // result.generatedMaps[0] contains generated column values
      
      // PREFERRED: update() for existing records - single UPDATE query
      await userRepo.update(
        { id: userId },
        { name: "Alice Updated", role: UserRole.ADMIN },
      );
      
      // PREFERRED: upsert() for insert-or-update - single query
      await userRepo.upsert(
        { email: "alice@example.com", name: "Alice", role: UserRole.USER },
        ["email"], // Conflict columns (must be unique/PK)
      );
      
      // save() - only when you need cascade saves or don't know the operation
      const user = userRepo.create({ email: "bob@example.com", name: "Bob" });
      const savedUser = await userRepo.save(user);
      ```
      
      **Why good:** `insert()` and `update()` each run 1 query, `save()` runs 2 (SELECT + INSERT/UPDATE); use `save()` only when cascades or ambiguity require it
      
      ### Good Example - Bulk Operations
      
      ```typescript
      // Bulk insert
      await userRepo.insert([
        { email: "user1@example.com", name: "User 1" },
        { email: "user2@example.com", name: "User 2" },
        { email: "user3@example.com", name: "User 3" },
      ]);
      
      // Bulk update
      await userRepo.update({ isActive: false }, { deletedAt: new Date() });
      
      // Bulk delete
      await userRepo.delete({ isActive: false });
      // Or by IDs:
      await userRepo.delete([id1, id2, id3]);
      ```
      
      **Why good:** Single query for batch operations, `delete` accepts array of IDs
      
      ---
      
      ## Soft Delete
      
      ### Good Example - Using @DeleteDateColumn
      
      ```typescript
      // Entity must have @DeleteDateColumn (see entity definition above)
      
      const userRepo = AppDataSource.getRepository(User);
      
      // Soft delete - sets deletedAt, doesn't remove row
      await userRepo.softDelete({ id: userId });
      
      // Restore - sets deletedAt back to null
      await userRepo.restore({ id: userId });
      
      // Find includes soft-deleted
      const allUsers = await userRepo.find({ withDeleted: true });
      
      // Find only soft-deleted
      const deletedUsers = await userRepo.find({
        where: { deletedAt: Not(IsNull()) },
        withDeleted: true,
      });
      ```
      
      **Why good:** `softDelete`/`restore` are built-in, `withDeleted: true` to include deleted records, `@DeleteDateColumn` works automatically
      
      ---
      
      ## Pagination
      
      ### Good Example - Offset Pagination with Total Count
      
      ```typescript
      const DEFAULT_PAGE_SIZE = 20;
      const MAX_PAGE_SIZE = 100;
      
      interface PaginationParams {
        page?: number;
        pageSize?: number;
      }
      
      const getUsers = async ({
        page = 1,
        pageSize = DEFAULT_PAGE_SIZE,
      }: PaginationParams) => {
        const take = Math.min(pageSize, MAX_PAGE_SIZE);
        const skip = (page - 1) * take;
      
        const [users, total] = await AppDataSource.getRepository(User).findAndCount({
          where: { isActive: true },
          order: { createdAt: "DESC" },
          take,
          skip,
        });
      
        return {
          data: users,
          pagination: {
            page,
            pageSize: take,
            total,
            totalPages: Math.ceil(total / take),
          },
        };
      };
      ```
      
      **Why good:** `findAndCount` returns data + total in one call, `Math.min` caps page size, named constants for limits
      
      ### Good Example - Cursor Pagination
      
      ```typescript
      const DEFAULT_PAGE_SIZE = 20;
      
      interface CursorParams {
        cursor?: string;
        take?: number;
      }
      
      const getPostsCursor = async ({
        cursor,
        take = DEFAULT_PAGE_SIZE,
      }: CursorParams) => {
        const qb = AppDataSource.getRepository(Post)
          .createQueryBuilder("post")
          .where("post.published = :pub", { pub: true })
          .orderBy("post.createdAt", "DESC")
          .take(take + 1); // Fetch one extra to detect next page
      
        if (cursor) {
          qb.andWhere("post.createdAt < :cursor", { cursor });
        }
      
        const posts = await qb.getMany();
        const hasNextPage = posts.length > take;
        const data = hasNextPage ? posts.slice(0, -1) : posts;
      
        return {
          data,
          nextCursor: hasNextPage
            ? data[data.length - 1]?.createdAt.toISOString()
            : undefined,
        };
      };
      ```
      
      **Why good:** Cursor-based scales to large datasets, `take + 1` pattern detects next page without extra count query
      
      ---
      
      ## Quick Reference
      
      | Operation         | Returns         | Throws on Not Found |
      | ----------------- | --------------- | ------------------- |
      | `findOneBy`       | `T \| null`     | No                  |
      | `findOneByOrFail` | `T`             | Yes                 |
      | `findOne`         | `T \| null`     | No                  |
      | `findOneOrFail`   | `T`             | Yes                 |
      | `find`            | `T[]`           | No (empty array)    |
      | `findAndCount`    | `[T[], number]` | No                  |
      | `insert`          | `InsertResult`  | N/A                 |
      | `update`          | `UpdateResult`  | No                  |
      | `upsert`          | `InsertResult`  | N/A                 |
      | `save`            | `T` or `T[]`    | N/A                 |
      | `delete`          | `DeleteResult`  | No                  |
      | `softDelete`      | `UpdateResult`  | No                  |
      | `restore`         | `UpdateResult`  | No                  |
      | `count`/`countBy` | `number`        | No                  |
      | `existsBy`        | `boolean`       | No                  |
      
    • migrations.md 8.4 KB
      # TypeORM - Migration Examples
      
      > Generate, run, revert migrations and CLI configuration. See [SKILL.md](../SKILL.md) for core concepts.
      
      **Prerequisites**: Understand DataSource configuration from [core.md](core.md).
      
      ---
      
      ## DataSource for Migrations
      
      ### Good Example - Separate Export for CLI
      
      ```typescript
      // data-source.ts - Used by BOTH app and migration CLI
      import { DataSource } from "typeorm";
      import { User } from "./entities/user.entity";
      import { Post } from "./entities/post.entity";
      
      export const AppDataSource = new DataSource({
        type: "postgres",
        host: process.env.DB_HOST ?? "localhost",
        port: Number(process.env.DB_PORT ?? 5432),
        username: process.env.DB_USER ?? "postgres",
        password: process.env.DB_PASS ?? "postgres",
        database: process.env.DB_NAME ?? "myapp",
        entities: [User, Post],
        migrations: ["./src/migrations/*.ts"],
        synchronize: false,
        logging: false,
      });
      ```
      
      **Why good:** Single DataSource file shared between app runtime and CLI, explicit entity list (no globs), migrations directory configured
      
      **Key point:** The CLI loads this file directly via `-d` flag. It must export a `DataSource` instance (not a function).
      
      ---
      
      ## CLI Commands
      
      ### Running Migrations
      
      ```bash
      # Generate migration from entity changes (auto-diff)
      npx typeorm-ts-node-esm migration:generate ./src/migrations/AddUserRole -d ./src/data-source.ts
      
      # Create empty migration (for manual SQL)
      npx typeorm-ts-node-esm migration:create ./src/migrations/SeedDefaultRoles
      
      # Run all pending migrations
      npx typeorm-ts-node-esm migration:run -d ./src/data-source.ts
      
      # Revert the last executed migration
      npx typeorm-ts-node-esm migration:revert -d ./src/data-source.ts
      
      # Show migration status
      npx typeorm-ts-node-esm migration:show -d ./src/data-source.ts
      ```
      
      **Key points:**
      
      - `migration:generate` compares entities to DB schema and generates SQL diff
      - `migration:revert` reverts only the LAST migration - call repeatedly to revert multiple
      - Always use `typeorm-ts-node-esm` for TypeScript projects (or `typeorm-ts-node-commonjs`)
      - The `-d` flag is required for run/revert/show/generate (points to DataSource file)
      
      ---
      
      ## Migration File Structure
      
      ### Good Example - Auto-Generated Migration
      
      ```typescript
      // src/migrations/1710000000000-AddUserRole.ts
      import type { MigrationInterface, QueryRunner } from "typeorm";
      
      export class AddUserRole1710000000000 implements MigrationInterface {
        name = "AddUserRole1710000000000";
      
        public async up(queryRunner: QueryRunner): Promise<void> {
          await queryRunner.query(
            `ALTER TABLE "users" ADD "role" character varying NOT NULL DEFAULT 'user'`,
          );
          await queryRunner.query(
            `CREATE INDEX "IDX_users_role" ON "users" ("role")`,
          );
        }
      
        public async down(queryRunner: QueryRunner): Promise<void> {
          await queryRunner.query(`DROP INDEX "IDX_users_role"`);
          await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "role"`);
        }
      }
      ```
      
      **Why good:** `up()` applies changes, `down()` reverts them exactly, index created with the column, timestamp ensures ordering
      
      ### Good Example - Manual Migration (Seed Data)
      
      ```typescript
      // src/migrations/1710000001000-SeedDefaultRoles.ts
      import type { MigrationInterface, QueryRunner } from "typeorm";
      
      export class SeedDefaultRoles1710000001000 implements MigrationInterface {
        name = "SeedDefaultRoles1710000001000";
      
        public async up(queryRunner: QueryRunner): Promise<void> {
          await queryRunner.query(`
            INSERT INTO "roles" ("id", "name", "description")
            VALUES
              (gen_random_uuid(), 'user', 'Default user role'),
              (gen_random_uuid(), 'admin', 'Administrator role'),
              (gen_random_uuid(), 'moderator', 'Content moderator')
            ON CONFLICT ("name") DO NOTHING
          `);
        }
      
        public async down(queryRunner: QueryRunner): Promise<void> {
          await queryRunner.query(`
            DELETE FROM "roles" WHERE "name" IN ('user', 'admin', 'moderator')
          `);
        }
      }
      ```
      
      **Why good:** `migration:create` generates empty file for manual SQL like seed data, `ON CONFLICT DO NOTHING` makes it idempotent, `down()` cleanly reverses
      
      ---
      
      ## Transaction Control in Migrations
      
      ### Good Example - Per-Migration Transaction Control
      
      ```bash
      # Default: all migrations in one transaction
      npx typeorm-ts-node-esm migration:run -d ./src/data-source.ts
      
      # Each migration in its own transaction
      npx typeorm-ts-node-esm migration:run -d ./src/data-source.ts --transaction each
      
      # No transactions (for DDL that can't run in transactions, e.g., CREATE INDEX CONCURRENTLY)
      npx typeorm-ts-node-esm migration:run -d ./src/data-source.ts --transaction none
      ```
      
      ### Good Example - Programmatic Transaction Control
      
      ```typescript
      // Per-migration transaction override
      export class CreateConcurrentIndex1710000002000 implements MigrationInterface {
        // This migration handles its own transaction
        transaction = false as const; // Disable auto-wrapping
      
        public async up(queryRunner: QueryRunner): Promise<void> {
          // CREATE INDEX CONCURRENTLY cannot run inside a transaction
          await queryRunner.query(
            `CREATE INDEX CONCURRENTLY "IDX_posts_title" ON "posts" ("title")`,
          );
        }
      
        public async down(queryRunner: QueryRunner): Promise<void> {
          await queryRunner.query(`DROP INDEX "IDX_posts_title"`);
        }
      }
      ```
      
      **Why good:** `transaction = false` disables transaction wrapping for this specific migration, necessary for PostgreSQL `CONCURRENTLY` operations
      
      ---
      
      ## Migration Workflow
      
      ### Recommended Process
      
      ```bash
      # 1. Modify entity class(es)
      
      # 2. Generate migration from diff
      npx typeorm-ts-node-esm migration:generate ./src/migrations/DescribeChange -d ./src/data-source.ts
      
      # 3. Review the generated SQL - always inspect before running!
      cat ./src/migrations/*-DescribeChange.ts
      
      # 4. Run migration on development
      npx typeorm-ts-node-esm migration:run -d ./src/data-source.ts
      
      # 5. Test the migration
      # ...run tests...
      
      # 6. If something went wrong, revert
      npx typeorm-ts-node-esm migration:revert -d ./src/data-source.ts
      
      # 7. Commit migration file with entity changes
      ```
      
      **Key points:**
      
      - Always review generated SQL before running - TypeORM can generate destructive changes
      - Entity renames generate DROP + CREATE (data loss) - use manual migration for renames
      - Run migrations in CI/CD pipeline, never use `synchronize: true`
      
      ---
      
      ## Common Migration Gotchas
      
      - **Column rename = data loss**: `migration:generate` creates DROP + ADD, not ALTER RENAME. Write manual migration for renames.
      - **Enum changes**: Adding/removing enum values requires manual SQL. Generated migration may fail on PostgreSQL.
      - **Default value changes**: TypeORM generates ALTER for existing rows but doesn't backfill. Consider manual UPDATE in migration.
      - **migration:run requires compiled JS** if not using ts-node. Compile TypeScript first or use `typeorm-ts-node-esm`.
      - **Migration table**: TypeORM tracks executed migrations in `migrations` table (configurable via `migrationsTableName`). Don't delete rows from it.
      
      ---
      
      ## package.json Scripts
      
      ### Good Example - Convenient Migration Scripts
      
      ```json
      {
        "scripts": {
          "migration:generate": "typeorm-ts-node-esm migration:generate -d ./src/data-source.ts",
          "migration:run": "typeorm-ts-node-esm migration:run -d ./src/data-source.ts",
          "migration:revert": "typeorm-ts-node-esm migration:revert -d ./src/data-source.ts",
          "migration:show": "typeorm-ts-node-esm migration:show -d ./src/data-source.ts",
          "migration:create": "typeorm-ts-node-esm migration:create"
        }
      }
      ```
      
      **Usage:** `npm run migration:generate -- ./src/migrations/AddUserEmail`
      
      ---
      
      ## Quick Reference
      
      | Command                     | Purpose                        | Requires `-d` |
      | --------------------------- | ------------------------------ | ------------- |
      | `migration:generate <path>` | Auto-generate from entity diff | Yes           |
      | `migration:create <path>`   | Create empty migration file    | No            |
      | `migration:run`             | Execute all pending migrations | Yes           |
      | `migration:revert`          | Revert last executed migration | Yes           |
      | `migration:show`            | List migrations with status    | Yes           |
      
      | Transaction Flag      | Behavior                                    |
      | --------------------- | ------------------------------------------- |
      | `--transaction all`   | All migrations in one transaction (default) |
      | `--transaction each`  | Each migration in its own transaction       |
      | `--transaction none`  | No transaction wrapping                     |
      | `transaction = false` | Per-migration override (in class)           |
      
    • query-builder.md 9.8 KB
      # TypeORM - QueryBuilder Examples
      
      > Joins, subqueries, pagination, aggregation, and raw queries. See [SKILL.md](../SKILL.md) for core concepts.
      
      **Prerequisites**: Understand entity definitions and repository patterns from [core.md](core.md).
      
      ---
      
      ## Basic QueryBuilder
      
      ### Good Example - Select with Conditions
      
      ```typescript
      const DEFAULT_PAGE_SIZE = 20;
      
      const users = await AppDataSource.getRepository(User)
        .createQueryBuilder("user")
        .select(["user.id", "user.name", "user.email"])
        .where("user.isActive = :active", { active: true })
        .andWhere("user.role = :role", { role: "admin" })
        .orderBy("user.createdAt", "DESC")
        .take(DEFAULT_PAGE_SIZE)
        .getMany();
      ```
      
      **Why good:** Parameterized queries prevent SQL injection, `select` reduces payload, `take` for entity-safe pagination
      
      ### Bad Example - String Interpolation
      
      ```typescript
      // BAD: SQL injection vulnerability
      const email = req.body.email;
      const user = await userRepo
        .createQueryBuilder("user")
        .where(`user.email = '${email}'`) // INJECTION!
        .getOne();
      ```
      
      **Why bad:** Attacker can pass `' OR 1=1 --` as email; always use `:paramName` with parameter objects
      
      ---
      
      ## Joins
      
      ### Good Example - Left Join and Select
      
      ```typescript
      // Load relation data (like find with relations, but with conditions)
      const usersWithPosts = await userRepo
        .createQueryBuilder("user")
        .leftJoinAndSelect("user.posts", "post")
        .where("user.isActive = :active", { active: true })
        .getMany();
      // Returns User[] with posts array populated
      
      // Conditional join - only load published posts
      const usersWithPublishedPosts = await userRepo
        .createQueryBuilder("user")
        .leftJoinAndSelect("user.posts", "post", "post.published = :pub", {
          pub: true,
        })
        .getMany();
      // Users without published posts still returned (left join), but posts array empty
      
      // Inner join - only users who HAVE published posts
      const activeAuthors = await userRepo
        .createQueryBuilder("user")
        .innerJoinAndSelect("user.posts", "post", "post.published = :pub", {
          pub: true,
        })
        .getMany();
      // Only users with at least one published post returned
      ```
      
      **Why good:** `leftJoinAndSelect` loads relations with optional condition, `innerJoinAndSelect` filters parent by relation existence, third parameter adds JOIN condition
      
      ### Good Example - Join Without Select (Filter Only)
      
      ```typescript
      // Filter by relation without loading it
      const usersWithRecentPosts = await userRepo
        .createQueryBuilder("user")
        .innerJoin("user.posts", "post") // Join but don't select post columns
        .where("post.createdAt > :date", { date: new Date("2024-01-01") })
        .getMany();
      // Returns User[] without posts populated (smaller payload)
      ```
      
      **Why good:** `innerJoin` without Select uses the relation for filtering without loading relation data - smaller payload
      
      ---
      
      ## Pagination with Joins
      
      ### Good Example - take/skip (Correct)
      
      ```typescript
      const DEFAULT_PAGE_SIZE = 20;
      const MAX_PAGE_SIZE = 100;
      
      const getPaginatedPosts = async (page: number, pageSize: number) => {
        const take = Math.min(pageSize, MAX_PAGE_SIZE);
        const skip = (page - 1) * take;
      
        const [posts, total] = await postRepo
          .createQueryBuilder("post")
          .leftJoinAndSelect("post.tags", "tag")
          .leftJoinAndSelect("post.author", "author")
          .where("post.published = :pub", { pub: true })
          .orderBy("post.createdAt", "DESC")
          .take(take) // Paginates on ENTITIES, not rows
          .skip(skip)
          .getManyAndCount();
      
        return { data: posts, total, page, pageSize: take };
      };
      ```
      
      **Why good:** `take`/`skip` handles pagination correctly with joins (paginates entities, not joined rows), `getManyAndCount` returns total in same query, `Math.min` caps page size
      
      ### Bad Example - limit/offset with Joins
      
      ```typescript
      // BAD: limit/offset counts joined rows, not entities
      const posts = await postRepo
        .createQueryBuilder("post")
        .leftJoinAndSelect("post.comments", "comment")
        .limit(10) // If a post has 5 comments, that's 5 rows for 1 post
        .offset(0) // You might get only 2-3 posts instead of 10
        .getMany();
      ```
      
      **Why bad:** `limit`/`offset` operate on SQL rows, not entities. A post with 5 comments counts as 5 rows, so `limit(10)` might return only 2 posts. Use `take`/`skip` instead.
      
      ---
      
      ## Subqueries
      
      ### Good Example - Subquery in WHERE
      
      ```typescript
      // Find users whose post count exceeds a threshold
      const MINIMUM_POST_COUNT = 5;
      
      const prolificAuthors = await userRepo
        .createQueryBuilder("user")
        .where((qb) => {
          const subQuery = qb
            .subQuery()
            .select("post.authorId")
            .from(Post, "post")
            .groupBy("post.authorId")
            .having("COUNT(post.id) >= :minPosts", { minPosts: MINIMUM_POST_COUNT })
            .getQuery();
          return `user.id IN ${subQuery}`;
        })
        .getMany();
      ```
      
      **Why good:** Subquery runs in DB (not in JS), named constant for threshold, parameters flow through correctly
      
      ### Good Example - Subquery in SELECT
      
      ```typescript
      // Add computed column via subquery
      const usersWithPostCount = await userRepo
        .createQueryBuilder("user")
        .addSelect((qb) => {
          return qb
            .subQuery()
            .select("COUNT(post.id)")
            .from(Post, "post")
            .where("post.authorId = user.id")
            .getQuery();
        }, "postCount")
        .getRawMany();
      // Returns raw objects: { user_id, user_name, ..., postCount }
      ```
      
      **Why good:** Computed column calculated in DB, correlated subquery references outer query, `getRawMany` for non-entity results
      
      ---
      
      ## Aggregation
      
      ### Good Example - Group By with Having
      
      ```typescript
      // Posts per author with minimum count filter
      const MINIMUM_POSTS = 3;
      
      const authorStats = await postRepo
        .createQueryBuilder("post")
        .select("post.authorId", "authorId")
        .addSelect("COUNT(post.id)", "postCount")
        .addSelect("MAX(post.createdAt)", "latestPost")
        .groupBy("post.authorId")
        .having("COUNT(post.id) >= :min", { min: MINIMUM_POSTS })
        .orderBy("postCount", "DESC")
        .getRawMany();
      // Returns: [{ authorId: "...", postCount: "5", latestPost: "..." }, ...]
      ```
      
      **Why good:** Aggregations run in DB, `HAVING` filters after grouping, `getRawMany` for aggregate results (not entity-shaped)
      
      **Note:** `getRawMany` returns raw objects with column aliases, not entity instances. Numeric aggregates may come back as strings depending on the driver.
      
      ---
      
      ## Raw Queries
      
      ### Good Example - Parameterized Raw Query
      
      ```typescript
      // When QueryBuilder is too limiting
      const result = await AppDataSource.query(
        `SELECT u.id, u.name, COUNT(p.id) as "postCount"
         FROM users u
         LEFT JOIN posts p ON p.author_id = u.id AND p.published = $1
         GROUP BY u.id, u.name
         HAVING COUNT(p.id) >= $2
         ORDER BY "postCount" DESC`,
        [true, 5], // Parameterized - safe from injection
      );
      // Returns raw rows: [{ id, name, postCount }, ...]
      ```
      
      **Why good:** Parameterized even in raw queries (no string interpolation), useful for complex queries that don't map well to QueryBuilder
      
      ### Bad Example - Unparameterized Raw Query
      
      ```typescript
      // BAD: String interpolation in raw query
      const result = await AppDataSource.query(
        `SELECT * FROM users WHERE email = '${email}'`, // SQL INJECTION!
      );
      ```
      
      **Why bad:** Same injection risk as QueryBuilder - always use parameter placeholders (`$1, $2` for PostgreSQL, `?, ?` for MySQL)
      
      ---
      
      ## Advanced Patterns
      
      ### Good Example - Conditional Query Building
      
      ```typescript
      interface PostFilterParams {
        authorId?: string;
        published?: boolean;
        search?: string;
        tags?: string[];
      }
      
      const DEFAULT_PAGE_SIZE = 20;
      
      const filterPosts = async (filters: PostFilterParams, page = 1) => {
        const qb = postRepo
          .createQueryBuilder("post")
          .leftJoinAndSelect("post.author", "author")
          .leftJoinAndSelect("post.tags", "tag");
      
        if (filters.authorId) {
          qb.andWhere("post.authorId = :authorId", { authorId: filters.authorId });
        }
      
        if (filters.published !== undefined) {
          qb.andWhere("post.published = :published", {
            published: filters.published,
          });
        }
      
        if (filters.search) {
          qb.andWhere("post.title ILIKE :search", { search: `%${filters.search}%` });
        }
      
        if (filters.tags?.length) {
          qb.andWhere("tag.name IN (:...tagNames)", { tagNames: filters.tags });
        }
      
        return qb
          .orderBy("post.createdAt", "DESC")
          .take(DEFAULT_PAGE_SIZE)
          .skip((page - 1) * DEFAULT_PAGE_SIZE)
          .getManyAndCount();
      };
      ```
      
      **Why good:** Conditions appended only when filters present, `:...param` spreads arrays into `IN` clause, QueryBuilder is mutable so conditions chain naturally
      
      ### Good Example - Locking
      
      ```typescript
      // Pessimistic lock - prevents concurrent reads/writes
      const user = await userRepo
        .createQueryBuilder("user")
        .setLock("pessimistic_write")
        .where("user.id = :id", { id: userId })
        .getOne();
      // Row locked until transaction commits/rolls back
      
      // Optimistic lock with version column
      @Entity("users")
      export class User {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @VersionColumn()
        version: number;
      }
      
      // Throws OptimisticLockVersionMismatchError if version changed
      await userRepo
        .createQueryBuilder()
        .update(User)
        .set({ name: "Updated" })
        .where("id = :id AND version = :version", {
          id: userId,
          version: expectedVersion,
        })
        .execute();
      ```
      
      **Why good:** Pessimistic lock for critical sections, optimistic lock via `@VersionColumn` for low-contention updates
      
      ---
      
      ## Quick Reference
      
      | Method            | Returns Entities | Use When                        |
      | ----------------- | ---------------- | ------------------------------- |
      | `getOne`          | Yes              | Single entity by condition      |
      | `getMany`         | Yes              | Multiple entities               |
      | `getManyAndCount` | Yes + count      | Paginated results               |
      | `getRawOne`       | No (raw)         | Single aggregate/computed value |
      | `getRawMany`      | No (raw)         | Multiple aggregates             |
      | `getCount`        | No (number)      | Count without loading entities  |
      | `execute`         | UpdateResult     | INSERT/UPDATE/DELETE via QB     |
      
    • relations.md 12 KB
      # TypeORM - Relations Examples
      
      > All relation types, eager/lazy loading, cascades, and relation gotchas. See [SKILL.md](../SKILL.md) for core concepts.
      
      **Prerequisites**: Understand entity definitions and repository patterns from [core.md](core.md).
      
      ---
      
      ## One-to-One Relations
      
      ### Good Example - Profile Relation
      
      ```typescript
      import {
        Entity,
        PrimaryGeneratedColumn,
        Column,
        OneToOne,
        JoinColumn,
      } from "typeorm";
      
      @Entity("profiles")
      export class Profile {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column({ type: "text", nullable: true })
        bio: string | null;
      
        @Column({ nullable: true })
        avatarUrl: string | null;
      
        // Owning side - MUST have @JoinColumn
        @OneToOne(() => User, (user) => user.profile, { onDelete: "CASCADE" })
        @JoinColumn({ name: "user_id" })
        user: User;
      
        @Column()
        userId: string; // Expose FK for direct queries
      }
      
      @Entity("users")
      export class User {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        // Inverse side - no @JoinColumn
        @OneToOne(() => Profile, (profile) => profile.user)
        profile: Profile;
      }
      ```
      
      **Why good:** `@JoinColumn` on owning side (Profile) with explicit column name, FK exposed as `userId`, `onDelete: CASCADE` cleans up profile when user deleted, inverse side for bidirectional navigation
      
      ### Bad Example - Missing JoinColumn
      
      ```typescript
      // BAD: @JoinColumn missing on @OneToOne
      @Entity()
      export class Profile {
        @OneToOne(() => User) // No @JoinColumn - TypeORM won't create FK column!
        user: User;
      }
      ```
      
      **Why bad:** `@OneToOne` requires `@JoinColumn` on the owning side - without it, no FK column is created and the relation won't persist
      
      ---
      
      ## Many-to-One / One-to-Many
      
      ### Good Example - Posts Belong to Author
      
      ```typescript
      @Entity("posts")
      export class Post {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column()
        title: string;
      
        @Column({ type: "text", nullable: true })
        content: string | null;
      
        @Column({ default: false })
        published: boolean;
      
        // Many posts belong to one user (owning side - holds FK)
        @ManyToOne(() => User, (user) => user.posts, {
          onDelete: "CASCADE",
          nullable: false,
        })
        @JoinColumn({ name: "author_id" })
        author: User;
      
        @Column({ name: "author_id" })
        authorId: string; // Direct FK access without join
      
        @CreateDateColumn()
        createdAt: Date;
      }
      
      @Entity("users")
      export class User {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column()
        name: string;
      
        // One user has many posts (inverse side)
        @OneToMany(() => Post, (post) => post.author)
        posts: Post[];
      }
      ```
      
      **Why good:** FK on Many side (Post), `authorId` exposed for queries without joining, `nullable: false` enforces required author, `onDelete: CASCADE` prevents orphaned posts
      
      ---
      
      ## Many-to-Many
      
      ### Good Example - Implicit Join Table
      
      ```typescript
      @Entity("posts")
      export class Post {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column()
        title: string;
      
        // @JoinTable on owning side (only one side)
        @ManyToMany(() => Tag, (tag) => tag.posts)
        @JoinTable({
          name: "posts_tags", // Explicit join table name
          joinColumn: { name: "post_id", referencedColumnName: "id" },
          inverseJoinColumn: { name: "tag_id", referencedColumnName: "id" },
        })
        tags: Tag[];
      }
      
      @Entity("tags")
      export class Tag {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @Column({ unique: true })
        name: string;
      
        // Inverse side - no @JoinTable
        @ManyToMany(() => Post, (post) => post.tags)
        posts: Post[];
      }
      ```
      
      **Why good:** `@JoinTable` on one side only with explicit names, bidirectional for querying from either side
      
      ### Good Example - Explicit Join Entity (Extra Fields on Relation)
      
      ```typescript
      // When you need extra columns on the many-to-many relationship
      @Entity("post_categories")
      export class PostCategory {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        @ManyToOne(() => Post, (post) => post.postCategories, { onDelete: "CASCADE" })
        @JoinColumn({ name: "post_id" })
        post: Post;
      
        @Column()
        postId: string;
      
        @ManyToOne(() => Category, (category) => category.postCategories, { onDelete: "CASCADE" })
        @JoinColumn({ name: "category_id" })
        category: Category;
      
        @Column()
        categoryId: string;
      
        @Column({ default: 0 })
        sortOrder: number; // Extra field on the relationship!
      
        @CreateDateColumn()
        assignedAt: Date; // Extra field!
      }
      
      // Post entity
      @OneToMany(() => PostCategory, (pc) => pc.post)
      postCategories: PostCategory[];
      
      // Category entity
      @OneToMany(() => PostCategory, (pc) => pc.category)
      postCategories: PostCategory[];
      ```
      
      **Why good:** Explicit join entity allows extra columns (`sortOrder`, `assignedAt`), standard ManyToOne/OneToMany patterns, can query the join entity directly
      
      ---
      
      ## Loading Relations
      
      ### Good Example - Explicit Loading with find()
      
      ```typescript
      const userRepo = AppDataSource.getRepository(User);
      
      // Load specific relations
      const userWithPosts = await userRepo.findOne({
        where: { id: userId },
        relations: { posts: true, profile: true },
      });
      
      // Nested relation loading
      const userWithPostTags = await userRepo.findOne({
        where: { id: userId },
        relations: {
          posts: {
            tags: true, // Load tags on each post
          },
        },
      });
      
      // Load relations with field selection
      const userSummary = await userRepo.findOne({
        where: { id: userId },
        select: {
          id: true,
          name: true,
          posts: { id: true, title: true },
        },
        relations: { posts: true },
      });
      ```
      
      **Why good:** Explicit relation loading prevents N+1, nested relations loaded in a single query, `select` reduces payload
      
      ### Bad Example - N+1 Query Problem
      
      ```typescript
      // BAD: Loading relations in a loop
      const users = await userRepo.find();
      for (const user of users) {
        user.posts = await postRepo.findBy({ authorId: user.id }); // N extra queries!
      }
      ```
      
      **Why bad:** 1 query for users + N queries for posts = N+1 queries, use `relations` option or QueryBuilder joins instead
      
      ---
      
      ## Eager vs Lazy Loading
      
      ### Good Example - Eager Relations (Automatic with find\*)
      
      ```typescript
      @Entity("users")
      export class User {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        // Eager: always loaded with find* methods
        @OneToOne(() => Profile, (profile) => profile.user, { eager: true })
        profile: Profile;
      
        // NOT eager: loaded only when explicitly requested
        @OneToMany(() => Post, (post) => post.author)
        posts: Post[];
      }
      
      // Profile is automatically included
      const user = await userRepo.findOneBy({ id: userId });
      // user.profile is loaded (eager)
      // user.posts is undefined (not loaded)
      
      // GOTCHA: eager does NOT work with QueryBuilder!
      const user = await userRepo
        .createQueryBuilder("user")
        .where("user.id = :id", { id: userId })
        .getOne();
      // user.profile is NOT loaded! Must use leftJoinAndSelect:
      const user = await userRepo
        .createQueryBuilder("user")
        .leftJoinAndSelect("user.profile", "profile")
        .where("user.id = :id", { id: userId })
        .getOne();
      ```
      
      **Why good:** Eager loading is convenient for always-needed relations, but the key gotcha is documented: it only works with `find*`, not QueryBuilder
      
      ### Good Example - Lazy Relations (Promise-Based)
      
      ```typescript
      @Entity("users")
      export class User {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        // Lazy: returns Promise, loads on access
        @OneToMany(() => Post, (post) => post.author)
        posts: Promise<Post[]>; // MUST be Promise type
      
        @OneToOne(() => Profile, (profile) => profile.user)
        profile: Promise<Profile>; // MUST be Promise type
      }
      
      // Usage - triggers a query when awaited
      const user = await userRepo.findOneBy({ id: userId });
      const posts = await user.posts; // Triggers SELECT query here
      ```
      
      **Why good:** Lazy loading defers queries until needed
      
      **Caveat:** Each `await user.posts` triggers a separate query. If accessed in a loop, this creates N+1 problems. Prefer explicit loading with `relations` for predictable performance.
      
      ---
      
      ## Cascade Operations
      
      ### Good Example - Selective Cascades
      
      ```typescript
      @Entity("users")
      export class User {
        @PrimaryGeneratedColumn("uuid")
        id: string;
      
        // Cascade only inserts - not updates or removes
        @OneToMany(() => Post, (post) => post.author, {
          cascade: ["insert"], // Only cascade new post creation
        })
        posts: Post[];
      
        @OneToOne(() => Profile, (profile) => profile.user, {
          cascade: true, // All cascades - use only when intentional
          onDelete: "CASCADE",
        })
        profile: Profile;
      }
      
      // Cascade insert: creating user also creates profile
      const user = new User();
      user.name = "Alice";
      user.profile = new Profile();
      user.profile.bio = "Developer";
      await userRepo.save(user); // Saves user AND profile
      
      // Cascade insert for posts
      user.posts = [new Post()];
      user.posts[0].title = "First Post";
      await userRepo.save(user); // Also inserts the new post
      ```
      
      **Why good:** `cascade: ["insert"]` is explicit about what cascades, prevents accidental updates/deletes of relations, `cascade: true` only for tightly coupled entities like User-Profile
      
      ### Bad Example - Unintended Cascade
      
      ```typescript
      // BAD: cascade: true saves anything attached to the entity
      @OneToMany(() => Post, (post) => post.author, { cascade: true })
      posts: Post[];
      
      // Loading user with posts, modifying a post accidentally, then saving
      const user = await userRepo.findOne({
        where: { id: userId },
        relations: { posts: true },
      });
      user.name = "Updated Name";
      // Oops - some code modified user.posts[0].title
      await userRepo.save(user); // Also updates the post! Unintended side effect
      ```
      
      **Why bad:** `cascade: true` propagates all operations including updates - any modification to loaded relations gets saved, risking unintended data changes
      
      ---
      
      ## Relation Queries with find()
      
      ### Good Example - Filter by Relation Data
      
      ```typescript
      // Find users who have published posts
      const usersWithPublishedPosts = await userRepo.find({
        where: {
          posts: {
            published: true,
          },
        },
        relations: { posts: true },
      });
      
      // Find posts by author role
      const adminPosts = await postRepo.find({
        where: {
          author: {
            role: UserRole.ADMIN,
          },
        },
        relations: { author: true },
      });
      ```
      
      **Why good:** Relation filters in `where` generate efficient JOINs, `relations` loads the matched data
      
      ---
      
      ## Working with Many-to-Many Relations
      
      ### Good Example - Add/Remove/Replace
      
      ```typescript
      const postRepo = AppDataSource.getRepository(Post);
      const tagRepo = AppDataSource.getRepository(Tag);
      
      // Add tags to post
      const post = await postRepo.findOne({
        where: { id: postId },
        relations: { tags: true },
      });
      
      const newTag = await tagRepo.findOneBy({ id: tagId });
      post.tags.push(newTag);
      await postRepo.save(post); // Updates join table
      
      // Remove a tag
      post.tags = post.tags.filter((t) => t.id !== removeTagId);
      await postRepo.save(post); // Updates join table
      
      // Replace all tags
      const newTags = await tagRepo.findBy({ id: In(newTagIds) });
      post.tags = newTags;
      await postRepo.save(post); // Replaces all in join table
      ```
      
      **Why good:** Push to add, filter to remove, assign to replace - TypeORM manages the join table
      
      **Caveat:** This requires loading existing relations first. For large datasets, use QueryBuilder to manipulate the join table directly.
      
      ---
      
      ## Quick Reference
      
      | Relation Type   | Decorator       | FK Location  | @JoinColumn | @JoinTable |
      | --------------- | --------------- | ------------ | ----------- | ---------- |
      | One-to-One      | `@OneToOne`     | Owning side  | Required    | No         |
      | Many-to-One     | `@ManyToOne`    | Many side    | Optional    | No         |
      | One-to-Many     | `@OneToMany`    | Inverse side | No          | No         |
      | Many-to-Many    | `@ManyToMany`   | Join table   | No          | Required   |
      | Explicit M-to-M | 2x `@ManyToOne` | Join entity  | Yes         | No         |
      
      | Loading Strategy | Pros                          | Cons                                | Use When                |
      | ---------------- | ----------------------------- | ----------------------------------- | ----------------------- |
      | Explicit         | Predictable, no extra queries | Must specify each time              | Default choice          |
      | Eager            | Automatic with find\*         | Not with QueryBuilder, always loads | Tightly coupled 1:1     |
      | Lazy             | Loads on demand               | N+1 risk, Promise syntax awkward    | Rarely used in practice |
      
    • transactions.md 9.9 KB
      # TypeORM - Transaction Examples
      
      > EntityManager callback, QueryRunner manual control, and isolation levels. See [SKILL.md](../SKILL.md) for core concepts.
      
      **Prerequisites**: Understand DataSource and repository patterns from [core.md](core.md).
      
      ---
      
      ## EntityManager Callback (Simple Approach)
      
      ### Good Example - Automatic Commit/Rollback
      
      ```typescript
      // All operations in callback succeed or fail together
      await AppDataSource.transaction(async (manager) => {
        const user = manager.create(User, {
          email: "alice@example.com",
          name: "Alice",
        });
        await manager.save(user);
      
        const post = manager.create(Post, {
          title: "First Post",
          authorId: user.id,
          published: true,
        });
        await manager.save(post);
      
        // If this throws, both user and post are rolled back
        await manager.insert(AuditLog, {
          action: "user_created",
          entityId: user.id,
        });
      });
      ```
      
      **Why good:** Auto-commits on success, auto-rollbacks on any throw, no manual cleanup needed, concise
      
      ### Bad Example - Using Global Manager in Transaction
      
      ```typescript
      // BAD: Global manager bypasses transaction!
      await AppDataSource.transaction(async (manager) => {
        await manager.save(User, userData); // In transaction
      
        // WRONG: This runs outside the transaction
        await AppDataSource.manager.save(Post, postData);
        // If manager.save(User) above fails, this Post is STILL saved
      });
      ```
      
      **Why bad:** `AppDataSource.manager` is the global entity manager - it doesn't participate in the transaction. Only the callback's `manager` parameter is transactional.
      
      ---
      
      ## EntityManager with Isolation Level
      
      ### Good Example - Serializable Transaction
      
      ```typescript
      // Specify isolation level as first parameter
      await AppDataSource.manager.transaction("SERIALIZABLE", async (manager) => {
        const account = await manager.findOneBy(Account, { id: fromAccountId });
      
        if (!account || account.balance < amount) {
          throw new Error("Insufficient funds");
        }
      
        await manager.update(
          Account,
          { id: fromAccountId },
          {
            balance: () => `balance - ${amount}`, // Raw SQL for atomic decrement
          },
        );
      
        await manager.update(
          Account,
          { id: toAccountId },
          {
            balance: () => `balance + ${amount}`,
          },
        );
      
        await manager.insert(Transfer, {
          fromAccountId,
          toAccountId,
          amount,
          timestamp: new Date(),
        });
      });
      ```
      
      **Why good:** `SERIALIZABLE` prevents concurrent modification of same accounts, atomic balance operations via raw SQL expression, business logic validated inside transaction
      
      ---
      
      ## QueryRunner (Full Control)
      
      ### Good Example - Manual Transaction Lifecycle
      
      ```typescript
      const MINIMUM_BALANCE = 0;
      
      const transferFunds = async (fromId: string, toId: string, amount: number) => {
        const queryRunner = AppDataSource.createQueryRunner();
      
        // Establish real database connection
        await queryRunner.connect();
      
        // Start transaction
        await queryRunner.startTransaction();
      
        try {
          // All operations use queryRunner.manager
          const sender = await queryRunner.manager.findOneBy(Account, { id: fromId });
          if (!sender || sender.balance - amount < MINIMUM_BALANCE) {
            throw new Error("Insufficient funds");
          }
      
          await queryRunner.manager.update(
            Account,
            { id: fromId },
            {
              balance: () => `balance - ${amount}`,
            },
          );
      
          await queryRunner.manager.update(
            Account,
            { id: toId },
            {
              balance: () => `balance + ${amount}`,
            },
          );
      
          await queryRunner.manager.insert(Transfer, {
            fromAccountId: fromId,
            toAccountId: toId,
            amount,
          });
      
          // Explicitly commit
          await queryRunner.commitTransaction();
      
          return { success: true };
        } catch (error) {
          // Explicitly rollback
          await queryRunner.rollbackTransaction();
          throw error;
        } finally {
          // ALWAYS release - returns connection to pool
          await queryRunner.release();
        }
      };
      ```
      
      **Why good:** Explicit commit/rollback control, `finally` block guarantees `release()`, all operations use `queryRunner.manager`, named constant for business rule
      
      ### Bad Example - Missing release()
      
      ```typescript
      // BAD: Connection leaked if exception occurs between connect and try block
      const queryRunner = AppDataSource.createQueryRunner();
      await queryRunner.connect();
      await queryRunner.startTransaction();
      
      try {
        await queryRunner.manager.save(User, userData);
        await queryRunner.commitTransaction();
      } catch (error) {
        await queryRunner.rollbackTransaction();
        throw error;
      }
      // Missing queryRunner.release() - connection never returned to pool!
      ```
      
      **Why bad:** Without `release()` in `finally`, the connection is permanently leaked. After enough transactions, the pool is exhausted and all queries block indefinitely.
      
      ---
      
      ## QueryRunner with Raw Queries
      
      ### Good Example - Using QueryRunner for Raw SQL in Transaction
      
      ```typescript
      const queryRunner = AppDataSource.createQueryRunner();
      await queryRunner.connect();
      await queryRunner.startTransaction();
      
      try {
        // Mix ORM and raw queries in same transaction
        await queryRunner.manager.save(User, userData);
      
        // Raw query on same transactional connection
        await queryRunner.query(
          `INSERT INTO "audit_logs" ("action", "entity_id", "created_at")
           VALUES ($1, $2, NOW())`,
          ["user_created", userData.id],
        );
      
        await queryRunner.commitTransaction();
      } catch (error) {
        await queryRunner.rollbackTransaction();
        throw error;
      } finally {
        await queryRunner.release();
      }
      ```
      
      **Why good:** `queryRunner.query()` runs on the same transactional connection, can mix ORM operations with raw SQL, parameterized query prevents injection
      
      ---
      
      ## Transaction with Repository
      
      ### Good Example - Getting Transactional Repository
      
      ```typescript
      await AppDataSource.transaction(async (manager) => {
        // Get repository scoped to this transaction
        const userRepo = manager.getRepository(User);
        const postRepo = manager.getRepository(Post);
      
        const user = await userRepo.findOneBy({ id: userId });
        if (!user) throw new Error("User not found");
      
        // These operations all use the transactional connection
        await userRepo.update({ id: userId }, { role: "admin" });
        await postRepo.update({ authorId: userId }, { published: true });
      });
      ```
      
      **Why good:** `manager.getRepository()` returns a repository scoped to the transaction - all operations through it participate in the transaction
      
      ### Bad Example - Using AppDataSource.getRepository in Transaction
      
      ```typescript
      // BAD: Global repository, not transactional
      await AppDataSource.transaction(async (manager) => {
        const userRepo = AppDataSource.getRepository(User); // WRONG!
        await userRepo.save(userData); // Runs outside transaction
      });
      ```
      
      **Why bad:** `AppDataSource.getRepository()` returns the global repository which operates outside the transaction context
      
      ---
      
      ## Error Handling in Transactions
      
      ### Good Example - Typed Error Handling
      
      ```typescript
      import { EntityNotFoundError, QueryFailedError } from "typeorm";
      
      const UNIQUE_VIOLATION_CODE = "23505"; // PostgreSQL
      const FK_VIOLATION_CODE = "23503"; // PostgreSQL
      
      const createUserWithProfile = async (email: string, name: string) => {
        try {
          return await AppDataSource.transaction(async (manager) => {
            const user = await manager.save(User, { email, name });
            await manager.save(Profile, { userId: user.id, bio: "" });
            return user;
          });
        } catch (error) {
          if (error instanceof QueryFailedError) {
            const driverError = error.driverError as { code?: string };
            if (driverError.code === UNIQUE_VIOLATION_CODE) {
              throw new Error("Email already registered");
            }
            if (driverError.code === FK_VIOLATION_CODE) {
              throw new Error("Referenced record not found");
            }
          }
          if (error instanceof EntityNotFoundError) {
            throw new Error("Required entity not found");
          }
          throw error;
        }
      };
      ```
      
      **Why good:** TypeORM error types for specific handling, PostgreSQL error codes as named constants, rethrows unknown errors
      
      ---
      
      ## Quick Reference
      
      | Approach                 | Auto Commit/Rollback | Isolation Level                        | Raw SQL Access            |
      | ------------------------ | -------------------- | -------------------------------------- | ------------------------- |
      | `DataSource.transaction` | Yes                  | Via first param                        | Via `manager.query()`     |
      | `manager.transaction`    | Yes                  | Via first param                        | Via `manager.query()`     |
      | `QueryRunner`            | No (manual)          | Via `startTransaction("SERIALIZABLE")` | Via `queryRunner.query()` |
      
      | Isolation Level    | Dirty Read | Non-Repeatable Read | Phantom Read | Use When                                |
      | ------------------ | ---------- | ------------------- | ------------ | --------------------------------------- |
      | `READ UNCOMMITTED` | Yes        | Yes                 | Yes          | Never (debugging only)                  |
      | `READ COMMITTED`   | No         | Yes                 | Yes          | Default for most operations             |
      | `REPEATABLE READ`  | No         | No                  | Yes          | Reports, consistent reads               |
      | `SERIALIZABLE`     | No         | No                  | No           | Financial transactions, critical writes |
      
      | Critical Rule                                                      | Reason                              |
      | ------------------------------------------------------------------ | ----------------------------------- |
      | Use `manager` param, not `AppDataSource.manager`                   | Global manager bypasses transaction |
      | Use `manager.getRepository()`, not `AppDataSource.getRepository()` | Same reason                         |
      | `queryRunner.release()` in `finally`                               | Prevents connection pool exhaustion |
      | Keep transactions short                                            | Long transactions lock rows/tables  |
      | Throw to rollback in callback transactions                         | Any exception triggers rollback     |
      
  • reference.md 13.3 KB
    # TypeORM Reference
    
    Decision frameworks, anti-patterns, performance optimization, and checklists for TypeORM.
    
    ---
    
    <decision_framework>
    
    ## Decision Framework
    
    ### Active Record vs Data Mapper?
    
    ```
    What's the project size and complexity?
    ├─ Small app, rapid prototype, few entities
    │   └─ Active Record (entities extend BaseEntity)
    ├─ Medium-large app, team collaboration
    │   └─ Data Mapper (repositories, separation of concerns)
    ├─ Need to unit test business logic without DB
    │   └─ Data Mapper (repositories are injectable/mockable)
    └─ DI-based framework (dependency injection)
        └─ Data Mapper (repositories are injectable/mockable)
    ```
    
    ### Which Write Method?
    
    ```
    Know the operation type?
    ├─ Definitely inserting new row(s)
    │   ├─ Single row → insert()
    │   └─ Multiple rows → insert([...]) (batch)
    ├─ Definitely updating existing row(s)
    │   ├─ By condition → update(criteria, partialEntity)
    │   └─ Increment/decrement → update(criteria, { count: () => "count + 1" })
    ├─ Insert or update (upsert)
    │   └─ upsert(entity, conflictColumns)
    ├─ Don't know if inserting or updating
    │   └─ save() (runs SELECT first - acceptable here)
    └─ Need cascade saves (nested relations)
        └─ save() (only method that triggers cascades)
    ```
    
    ### Which Read Method?
    
    ```
    What data do you need?
    ├─ Single record by primary key or unique field
    │   ├─ May not exist → findOne({ where: { id } })
    │   └─ Must exist (throw if missing) → findOneOrFail({ where: { id } })
    ├─ Multiple records
    │   ├─ Simple filters → find({ where, order, take, skip })
    │   └─ Complex joins/subqueries → createQueryBuilder()
    ├─ Count only
    │   └─ count({ where })
    ├─ Check existence
    │   └─ exists({ where }) or existsBy({ field })
    └─ Aggregate (SUM, AVG, etc.)
        └─ createQueryBuilder().select("SUM(...)").getRawOne()
    ```
    
    ### QueryBuilder vs find\*?
    
    ```
    What's the query complexity?
    ├─ Simple CRUD with filters
    │   └─ find/findOne (cleaner, fully typed)
    ├─ Need joins with conditions
    │   ├─ Eager relations (unconditional) → find with relations option
    │   └─ Conditional joins → createQueryBuilder with leftJoinAndSelect
    ├─ Need subqueries
    │   └─ createQueryBuilder with .subQuery()
    ├─ Need aggregations (GROUP BY, HAVING)
    │   └─ createQueryBuilder with .groupBy().having()
    ├─ Need raw SQL fragments
    │   └─ createQueryBuilder with .addSelect(() => subQuery)
    └─ Need pagination with joins
        └─ createQueryBuilder with .take()/.skip() (NOT limit/offset)
    ```
    
    ### Which Transaction Approach?
    
    ```
    What level of control do you need?
    ├─ Simple: all operations succeed or all fail
    │   └─ DataSource.transaction(async (manager) => { ... })
    ├─ Need specific isolation level
    │   └─ DataSource.manager.transaction("SERIALIZABLE", async (manager) => { ... })
    ├─ Need manual commit/rollback control
    │   └─ QueryRunner (connect, startTransaction, commit/rollback, release)
    ├─ Need to reuse connection across operations
    │   └─ QueryRunner (single connection instance)
    └─ Nested transactions / savepoints
        └─ QueryRunner with createQueryRunner() per level
    ```
    
    </decision_framework>
    
    ---
    
    <performance>
    
    ## Performance Optimization
    
    ### Indexing Strategy
    
    Add indexes for columns used in WHERE, ORDER BY, and JOIN conditions:
    
    ```typescript
    @Entity("posts")
    @Index(["authorId"]) // Single column - FK lookups
    @Index(["authorId", "published"]) // Composite - common query pattern
    @Index(["createdAt"]) // Sort by date
    @Index(["title"], { fulltext: true }) // Full-text search (MySQL/PostgreSQL)
    export class Post {
      @PrimaryGeneratedColumn("uuid")
      id: string;
    
      @Column()
      @Index({ unique: true }) // Inline unique index
      slug: string;
    
      @Column()
      authorId: string;
    
      @Column({ default: false })
      published: boolean;
    
      @CreateDateColumn()
      createdAt: Date;
    }
    ```
    
    ### Avoid save() for Known Operations
    
    ```typescript
    // WRONG: save() for bulk inserts (2N queries)
    for (const item of items) {
      await repo.save(item);
    }
    
    // CORRECT: insert() for bulk (1 query)
    await repo.insert(items);
    
    // WRONG: save() for updating one field (SELECT + UPDATE)
    const user = await repo.findOneBy({ id: userId });
    user.name = "New Name";
    await repo.save(user);
    
    // CORRECT: update() directly (1 query)
    await repo.update({ id: userId }, { name: "New Name" });
    ```
    
    ### Use select to Reduce Payload
    
    ```typescript
    // WRONG: Loading all columns when you need two
    const users = await userRepo.find();
    
    // CORRECT: Select only needed columns
    const users = await userRepo.find({
      select: { id: true, name: true },
    });
    ```
    
    ### take/skip vs limit/offset in QueryBuilder
    
    ```typescript
    // WRONG: limit/offset with joins returns wrong count
    const posts = await postRepo
      .createQueryBuilder("post")
      .leftJoinAndSelect("post.comments", "comment")
      .limit(10) // Limits total ROWS including joined rows
      .offset(0)
      .getMany();
    
    // CORRECT: take/skip works on entities, not rows
    const posts = await postRepo
      .createQueryBuilder("post")
      .leftJoinAndSelect("post.comments", "comment")
      .take(10) // Returns 10 posts (regardless of comment count)
      .skip(0)
      .getMany();
    ```
    
    ### Batch Operations
    
    ```typescript
    // WRONG: Individual operations in a loop
    for (const id of deleteIds) {
      await repo.delete(id);
    }
    
    // CORRECT: Batch delete
    await repo.delete(deleteIds);
    
    // CORRECT: Batch update with QueryBuilder
    await repo
      .createQueryBuilder()
      .update(Post)
      .set({ published: true })
      .where("authorId = :authorId", { authorId })
      .andWhere("status = :status", { status: "reviewed" })
      .execute();
    ```
    
    ### Connection Management
    
    ```typescript
    // Pool configuration in DataSource options
    const AppDataSource = new DataSource({
      type: "postgres",
      extra: {
        max: 20, // Max connections in pool
        idleTimeoutMillis: 10000,
        connectionTimeoutMillis: 3000,
      },
    });
    
    // Graceful shutdown
    process.on("SIGTERM", async () => {
      await AppDataSource.destroy();
      process.exit(0);
    });
    ```
    
    </performance>
    
    ---
    
    <anti_patterns>
    
    ## Anti-Patterns to Avoid
    
    ### Using synchronize in Production
    
    ```typescript
    // ANTI-PATTERN
    const AppDataSource = new DataSource({
      synchronize: true, // NEVER in production
    });
    ```
    
    **Why it's wrong:** `synchronize` compares entities to DB schema and alters tables on every startup. Renaming a column creates a new column and drops the old one - losing all data in that column.
    
    **What to do instead:** Use migrations: `migration:generate` to create, `migration:run` to apply.
    
    ---
    
    ### save() for Everything
    
    ```typescript
    // ANTI-PATTERN: Always using save()
    const newUser = repo.create({ email, name });
    await repo.save(newUser); // SELECT + INSERT = 2 queries
    
    const existingUser = await repo.findOneBy({ id });
    existingUser.name = "Updated";
    await repo.save(existingUser); // SELECT + UPDATE = 2 queries
    ```
    
    **Why it's wrong:** `save()` runs a SELECT before every INSERT or UPDATE to determine which to execute. For known operations, this doubles query count. The SELECT includes a subquery that is slow on large tables.
    
    **What to do instead:** Use `insert()` for creates, `update()` for updates, `upsert()` for upsert.
    
    ---
    
    ### Global Manager in Transactions
    
    ```typescript
    // ANTI-PATTERN
    await AppDataSource.transaction(async (manager) => {
      // WRONG: using global repository/manager
      await AppDataSource.getRepository(User).save(userData);
      // WRONG: using global manager
      await AppDataSource.manager.save(Post, postData);
      // Only this is correct:
      await manager.save(Post, otherData);
    });
    ```
    
    **Why it's wrong:** Global manager/repositories execute outside the transaction. If `manager.save(Post)` fails, the `User` save won't roll back.
    
    **What to do instead:** All operations inside the callback must use the `manager` parameter (or `queryRunner.manager` for QueryRunner transactions).
    
    ---
    
    ### Eager Loading on Both Sides
    
    ```typescript
    // ANTI-PATTERN
    @Entity()
    export class User {
      @OneToMany(() => Post, (post) => post.author, { eager: true })
      posts: Post[];
    }
    
    @Entity()
    export class Post {
      @ManyToOne(() => User, (user) => user.posts, { eager: true }) // ERROR
      author: User;
    }
    ```
    
    **Why it's wrong:** TypeORM forbids `eager: true` on both sides of a relation - it would cause infinite recursion. This throws an error at runtime.
    
    **What to do instead:** Set `eager: true` on only one side, or omit it entirely and load relations explicitly.
    
    ---
    
    ### Initializing Relation Arrays
    
    ```typescript
    // ANTI-PATTERN
    @Entity()
    export class Question {
      @ManyToMany(() => Category)
      @JoinTable()
      categories: Category[] = []; // Initializing to empty array
    }
    ```
    
    **Why it's wrong:** When you `save()` a Question loaded from the DB, TypeORM sees the empty array and detaches all existing categories. The initialization overwrites the loaded relation data.
    
    **What to do instead:** Don't initialize relation properties. Let TypeORM manage them.
    
    ---
    
    ### Missing QueryRunner Release
    
    ```typescript
    // ANTI-PATTERN
    const queryRunner = AppDataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    try {
      await queryRunner.manager.save(User, userData);
      await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    }
    // Missing queryRunner.release() - connection leaked!
    ```
    
    **Why it's wrong:** Without `release()` in a `finally` block, the connection is never returned to the pool. After enough leaked connections, the pool is exhausted and all queries block.
    
    **What to do instead:** Always call `queryRunner.release()` in a `finally` block.
    
    </anti_patterns>
    
    ---
    
    ## Quick Reference Tables
    
    ### Column Type Mapping
    
    | TypeScript Type | TypeORM Column            | PostgreSQL     | MySQL          |
    | --------------- | ------------------------- | -------------- | -------------- |
    | `string`        | `@Column()`               | `varchar(255)` | `varchar(255)` |
    | `string`        | `@Column("text")`         | `text`         | `text`         |
    | `number`        | `@Column("int")`          | `integer`      | `int`          |
    | `number`        | `@Column("decimal")`      | `numeric`      | `decimal`      |
    | `boolean`       | `@Column()`               | `boolean`      | `tinyint(1)`   |
    | `Date`          | `@Column("timestamp")`    | `timestamp`    | `datetime`     |
    | `object`        | `@Column("jsonb")`        | `jsonb`        | `json`         |
    | `string[]`      | `@Column("simple-array")` | `text`         | `text`         |
    | `object`        | `@Column("simple-json")`  | `text`         | `text`         |
    
    ### Relation Decorator Rules
    
    | Relation     | Decorator     | @JoinColumn            | @JoinTable          | FK Column On |
    | ------------ | ------------- | ---------------------- | ------------------- | ------------ |
    | One-to-One   | `@OneToOne`   | Required (owning side) | No                  | Owning side  |
    | Many-to-One  | `@ManyToOne`  | Optional               | No                  | Many side    |
    | One-to-Many  | `@OneToMany`  | No                     | No                  | Other side   |
    | Many-to-Many | `@ManyToMany` | No                     | Required (one side) | Join table   |
    
    ### find\* Options
    
    | Option        | Purpose                    | Example                    |
    | ------------- | -------------------------- | -------------------------- |
    | `where`       | Filter conditions          | `{ role: "admin" }`        |
    | `relations`   | Load relations             | `{ posts: true }`          |
    | `select`      | Pick columns               | `{ id: true, name: true }` |
    | `order`       | Sort results               | `{ createdAt: "DESC" }`    |
    | `take`        | Limit count                | `20`                       |
    | `skip`        | Offset                     | `0`                        |
    | `withDeleted` | Include soft-deleted       | `true`                     |
    | `cache`       | Cache results (ms or bool) | `60000`                    |
    
    ### Migration Commands
    
    | Command                             | Purpose                        |
    | ----------------------------------- | ------------------------------ |
    | `migration:generate <path> -d <ds>` | Auto-generate from entity diff |
    | `migration:create <path>`           | Create empty migration file    |
    | `migration:run -d <ds>`             | Execute pending migrations     |
    | `migration:revert -d <ds>`          | Revert last executed migration |
    | `migration:show -d <ds>`            | Show all migrations and status |
    
    ---
    
    ## Checklists
    
    ### Before Deploying
    
    - [ ] `synchronize: false` in production DataSource
    - [ ] All entity changes captured in migrations
    - [ ] Indexes on frequently filtered/sorted columns
    - [ ] `onDelete` cascade configured on child relations
    - [ ] Connection pool limits configured for environment
    - [ ] Graceful shutdown calls `AppDataSource.destroy()`
    - [ ] `reflect-metadata` imported at application entry point
    
    ### Code Review Checklist
    
    - [ ] `insert()`/`update()` used instead of `save()` where operation is known
    - [ ] No string interpolation in QueryBuilder `.where()` - parameterized queries only
    - [ ] `take()`/`skip()` used instead of `limit()`/`offset()` with joins
    - [ ] QueryRunner always released in `finally` block
    - [ ] Transaction callback uses provided `manager`, not global one
    - [ ] Relation properties NOT initialized with `= []`
    - [ ] FK columns exposed alongside relation properties for simple lookups
    - [ ] Named constants for pagination limits and timeouts
    
  • SKILL.md 17.1 KB
    ---
    name: api-database-typeorm
    description: Decorator-based ORM for TypeScript with Active Record and Data Mapper patterns
    ---
    
    # Database with TypeORM
    
    > **Quick Guide:** Use TypeORM for decorator-based database access with full TypeScript support. Schema defined via entity classes with `@Entity`, `@Column`, `@PrimaryGeneratedColumn`. Use Data Mapper pattern (repositories) over Active Record for non-trivial apps. **Never use `synchronize: true` in production** - use migrations. Prefer `insert()`/`update()` over `save()` when you know the operation type - `save()` always executes a SELECT first. Use `QueryRunner` transactions for full control. Eager relations only work with `find*` methods, not QueryBuilder.
    
    ---
    
    <critical_requirements>
    
    ## CRITICAL: Before Using This Skill
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST NEVER use `synchronize: true` in production - it can drop columns and lose data when entities change)**
    
    **(You MUST use `insert()`/`update()` instead of `save()` when the operation type is known - `save()` always runs an extra SELECT query)**
    
    **(You MUST use the provided transaction `manager` parameter or `queryRunner.manager` inside transactions - NEVER use the global entity manager or repository)**
    
    **(You MUST define relations with explicit `@JoinColumn()` on the owning side of `@OneToOne` and optionally `@ManyToOne`, and `@JoinTable()` on one side of `@ManyToMany`)**
    
    </critical_requirements>
    
    ---
    
    **Auto-detection:** typeorm, TypeORM, DataSource, @Entity, @Column, @PrimaryGeneratedColumn, @ManyToOne, @OneToMany, @ManyToMany, createQueryBuilder, getRepository, EntityManager, QueryRunner, migration:generate, migration:run
    
    **When to use:**
    
    - Decorator-based entity definitions with TypeScript
    - Applications requiring both Active Record and Data Mapper patterns
    - Complex queries needing QueryBuilder with joins and subqueries
    - Projects where class-based ORM feels natural (especially with DI-based frameworks)
    
    **When NOT to use:**
    
    - Schema-first workflows (consider schema-first ORMs instead)
    - Needing fully type-safe queries without runtime decorators (consider lighter ORMs)
    - Edge/serverless with minimal cold start (decorator metadata adds weight)
    - Projects avoiding `reflect-metadata` and `experimentalDecorators`
    
    **Key patterns covered:**
    
    - DataSource configuration and entity registration
    - Entity definitions with decorators and column types
    - Relations (OneToOne, OneToMany, ManyToOne, ManyToMany)
    - Repository CRUD and QueryBuilder
    - Migrations (generate, run, revert)
    - Transactions (EntityManager callback, QueryRunner manual)
    - `save()` vs `insert()`/`update()` performance
    
    **Detailed Resources:**
    
    - [examples/core.md](examples/core.md) - DataSource setup, entities, CRUD, repository patterns
    - [examples/relations.md](examples/relations.md) - All relation types, eager/lazy loading, cascades
    - [examples/query-builder.md](examples/query-builder.md) - Joins, subqueries, pagination, raw queries
    - [examples/migrations.md](examples/migrations.md) - Generate, run, revert, CLI configuration
    - [examples/transactions.md](examples/transactions.md) - EntityManager, QueryRunner, isolation levels
    - [examples/advanced.md](examples/advanced.md) - Subscribers, listeners, tree entities, embedded entities
    - [reference.md](reference.md) - Decision frameworks, anti-patterns, performance, checklists
    
    ---
    
    <philosophy>
    
    ## Philosophy
    
    **TypeORM** uses TypeScript decorators to define database entities as classes. It supports both the Active Record and Data Mapper patterns, giving teams flexibility in how they structure data access.
    
    **Core principles:**
    
    1. **Decorator-based schema** - Entities are classes decorated with `@Entity`, `@Column`, etc.
    2. **Pattern flexibility** - Active Record for simplicity, Data Mapper for separation of concerns
    3. **QueryBuilder power** - SQL-like fluent API for complex queries beyond simple `find*`
    4. **Migration-driven** - Schema changes through versioned migration files, never auto-sync in production
    
    **Active Record vs Data Mapper:**
    
    - **Active Record**: Entities extend `BaseEntity`, call `User.find()`, `user.save()` directly. Good for small apps and rapid prototyping.
    - **Data Mapper**: Entities are plain classes, repositories handle persistence (`userRepo.find()`, `userRepo.save()`). Better for complex apps, testing, and separation of concerns.
    
    **Recommendation:** Use Data Mapper for any non-trivial application. Active Record couples domain logic to persistence, making testing and refactoring harder.
    
    </philosophy>
    
    ---
    
    <patterns>
    
    ## Core Patterns
    
    ### Pattern 1: DataSource Configuration
    
    Configure the DataSource as a singleton. Export it for both the application and migration CLI.
    
    ```typescript
    // data-source.ts
    import { DataSource } from "typeorm";
    import { User } from "./entities/user.entity";
    import { Post } from "./entities/post.entity";
    
    export const AppDataSource = new DataSource({
      type: "postgres",
      host: process.env.DB_HOST,
      port: Number(process.env.DB_PORT),
      username: process.env.DB_USER,
      password: process.env.DB_PASS,
      database: process.env.DB_NAME,
      entities: [User, Post],
      migrations: ["./src/migrations/*.ts"],
      synchronize: false, // NEVER true in production
      logging: process.env.NODE_ENV === "development",
    });
    ```
    
    **Why good:** Single DataSource export used by both app and CLI, `synchronize: false` prevents data loss, env vars for config
    
    ```typescript
    // BAD: synchronize in production
    const AppDataSource = new DataSource({
      synchronize: true, // Drops columns, loses data on entity changes
      entities: ["./src/**/*.entity.ts"], // Glob patterns are fragile
    });
    ```
    
    **Why bad:** `synchronize: true` alters schema on startup (can drop columns with data), glob entity paths break with bundlers and are non-deterministic
    
    > See [examples/core.md](examples/core.md) for initialization, graceful shutdown, and entity registration patterns.
    
    ---
    
    ### Pattern 2: Entity Definition
    
    Entities are classes with decorators mapping to database tables and columns.
    
    ```typescript
    import {
      Entity,
      PrimaryGeneratedColumn,
      Column,
      CreateDateColumn,
      UpdateDateColumn,
      Index,
    } from "typeorm";
    
    @Entity("users") // Explicit table name
    export class User {
      @PrimaryGeneratedColumn("uuid")
      id: string;
    
      @Column({ unique: true })
      email: string;
    
      @Column()
      name: string;
    
      @Column({ type: "enum", enum: ["user", "admin"], default: "user" })
      role: string;
    
      @CreateDateColumn()
      createdAt: Date;
    
      @UpdateDateColumn()
      updatedAt: Date;
    }
    ```
    
    **Why good:** Explicit table name avoids casing issues, `uuid` for distributed-safe IDs, `CreateDateColumn`/`UpdateDateColumn` auto-managed by TypeORM, enum column with default
    
    ```typescript
    // BAD: Missing explicit table name, no index on frequently queried column
    @Entity() // Table name derived from class name - casing varies by database
    export class UserProfile {
      @PrimaryGeneratedColumn() // Auto-increment integer - problematic for distributed systems
      id: number;
    
      @Column()
      userId: string; // No index, no foreign key relation defined
    }
    ```
    
    **Why bad:** Derived table names cause casing inconsistency across databases, auto-increment IDs conflict in distributed systems, missing indexes on lookup columns
    
    > See [examples/core.md](examples/core.md) for column types, nullable columns, and default values.
    
    ---
    
    ### Pattern 3: Repository CRUD - `save()` vs `insert()`/`update()`
    
    The critical performance distinction: `save()` always runs a SELECT first. Use `insert()`/`update()` when you know the operation.
    
    ```typescript
    const userRepo = AppDataSource.getRepository(User);
    
    // CREATING: Use insert() - single INSERT query
    await userRepo.insert({
      email: "alice@example.com",
      name: "Alice",
    });
    
    // UPDATING: Use update() - single UPDATE query
    const ACTIVE_ROLE = "admin";
    await userRepo.update({ id: userId }, { role: ACTIVE_ROLE });
    
    // UPSERTING: Use upsert() - INSERT ... ON CONFLICT
    await userRepo.upsert(
      { email: "alice@example.com", name: "Alice Updated" },
      ["email"], // conflict columns
    );
    
    // save() - only when you need cascade saves or don't know if inserting/updating
    const user = userRepo.create({ email: "bob@example.com", name: "Bob" });
    await userRepo.save(user); // SELECT + INSERT (2 queries)
    ```
    
    **Why good:** `insert()`/`update()` execute single queries, `upsert()` handles conflicts atomically, `save()` reserved for when cascades or ambiguous operations are needed
    
    ```typescript
    // BAD: Using save() for everything
    const user = new User();
    user.email = "alice@example.com";
    user.name = "Alice";
    await userRepo.save(user); // Runs SELECT first, then INSERT - 2 round trips
    
    // BAD: Using save() in a loop
    for (const data of users) {
      await userRepo.save(data); // 2N queries instead of 1 bulk insert
    }
    ```
    
    **Why bad:** `save()` always runs SELECT + INSERT/UPDATE (2 round trips), in loops this becomes 2N queries; use `insert()` for bulk creates
    
    > See [examples/core.md](examples/core.md) for find operations, bulk operations, and soft delete patterns.
    
    ---
    
    ### Pattern 4: Relations
    
    Define relations with decorators. The owning side holds the foreign key.
    
    ```typescript
    import {
      Entity,
      PrimaryGeneratedColumn,
      Column,
      ManyToOne,
      OneToMany,
      JoinColumn,
    } from "typeorm";
    
    @Entity("posts")
    export class Post {
      @PrimaryGeneratedColumn("uuid")
      id: string;
    
      @Column()
      title: string;
    
      // Owning side - holds the foreign key column
      @ManyToOne(() => User, (user) => user.posts, { onDelete: "CASCADE" })
      @JoinColumn({ name: "author_id" }) // Explicit FK column name
      author: User;
    
      @Column()
      authorId: string; // Expose FK for queries without joining
    }
    
    @Entity("users")
    export class User {
      @PrimaryGeneratedColumn("uuid")
      id: string;
    
      // Inverse side - no FK column here
      @OneToMany(() => Post, (post) => post.author)
      posts: Post[];
    }
    ```
    
    **Why good:** Explicit `@JoinColumn` names the FK column, `authorId` exposed for direct queries, `onDelete: "CASCADE"` prevents orphans, inverse side defined for bidirectional navigation
    
    ```typescript
    // BAD: Missing JoinColumn, no onDelete, relation typed as required
    @ManyToOne(() => User)
    author: User; // No explicit FK column name, no cascade delete
    ```
    
    **Why bad:** Auto-generated FK column name may not match conventions, missing `onDelete` leaves orphaned rows, relation property should be `User | undefined` since it's not always loaded
    
    > See [examples/relations.md](examples/relations.md) for all relation types, ManyToMany with JoinTable, and eager/lazy loading.
    
    ---
    
    ### Pattern 5: QueryBuilder
    
    For queries beyond simple `find*`, use the QueryBuilder's fluent API.
    
    ```typescript
    const DEFAULT_PAGE_SIZE = 20;
    const MAX_PAGE_SIZE = 100;
    
    const users = await AppDataSource.getRepository(User)
      .createQueryBuilder("user")
      .leftJoinAndSelect("user.posts", "post", "post.published = :pub", {
        pub: true,
      })
      .where("user.role = :role", { role: "admin" })
      .andWhere("user.createdAt > :date", { date: new Date("2024-01-01") })
      .orderBy("user.createdAt", "DESC")
      .take(DEFAULT_PAGE_SIZE)
      .skip(0)
      .getMany();
    ```
    
    **Why good:** Parameterized queries prevent SQL injection, `leftJoinAndSelect` loads relations in one query, `take`/`skip` for pagination (relation-safe unlike `limit`/`offset`)
    
    ```typescript
    // BAD: String interpolation in where clause
    const users = await userRepo
      .createQueryBuilder("user")
      .where(`user.email = '${email}'`) // SQL INJECTION!
      .getMany();
    ```
    
    **Why bad:** String interpolation opens SQL injection vulnerability; always use `:paramName` with parameter objects
    
    > See [examples/query-builder.md](examples/query-builder.md) for subqueries, aggregations, raw queries, and advanced joins.
    
    ---
    
    ### Pattern 6: Migrations
    
    Generate migrations from entity changes, never manually write SQL unless necessary.
    
    ```bash
    # Generate migration from entity diff
    npx typeorm-ts-node-esm migration:generate ./src/migrations/AddUserRole -d ./src/data-source.ts
    
    # Run all pending migrations
    npx typeorm-ts-node-esm migration:run -d ./src/data-source.ts
    
    # Revert last migration
    npx typeorm-ts-node-esm migration:revert -d ./src/data-source.ts
    ```
    
    **Why good:** Auto-generated migrations capture exact schema diff, `-d` flag points to DataSource config, `revert` undoes one migration at a time
    
    > See [examples/migrations.md](examples/migrations.md) for migration class structure, manual migrations, and transaction control.
    
    ---
    
    ### Pattern 7: Transactions
    
    Two approaches: EntityManager callback (simple) and QueryRunner (full control).
    
    ```typescript
    // Approach 1: EntityManager callback - simple, auto-commits/rollbacks
    await AppDataSource.transaction(async (manager) => {
      await manager.save(User, userData);
      await manager.save(Post, postData);
      // If any operation throws, entire transaction rolls back
    });
    
    // Approach 2: QueryRunner - manual control, reusable connection
    const queryRunner = AppDataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    try {
      await queryRunner.manager.save(User, userData);
      await queryRunner.manager.save(Post, postData);
      await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release(); // ALWAYS release
    }
    ```
    
    **Why good:** EntityManager callback is concise with auto-rollback, QueryRunner gives explicit commit/rollback control, `finally` block ensures connection release
    
    ```typescript
    // BAD: Using global manager inside transaction
    await AppDataSource.transaction(async (manager) => {
      await AppDataSource.manager.save(User, userData); // WRONG: bypasses transaction!
      await manager.save(Post, postData);
    });
    ```
    
    **Why bad:** `AppDataSource.manager` is the global manager, not the transactional one - operations using it run outside the transaction and won't roll back
    
    > See [examples/transactions.md](examples/transactions.md) for isolation levels, QueryRunner patterns, and nested transactions.
    
    </patterns>
    
    ---
    
    <red_flags>
    
    ## RED FLAGS
    
    **High Priority Issues:**
    
    - `synchronize: true` in production - alters schema on startup, can drop columns and lose data
    - Using `save()` for all writes - always runs SELECT first, 2x round trips for known inserts/updates
    - String interpolation in QueryBuilder `.where()` - SQL injection vulnerability
    - Using global entity manager inside transactions - bypasses transaction context
    - Missing `queryRunner.release()` in finally block - leaks database connections
    
    **Medium Priority Issues:**
    
    - No indexes on frequently filtered columns - slow queries as data grows
    - Missing `onDelete` cascade on relations - orphaned rows when parent deleted
    - Using `eager: true` on both sides of a relation - TypeORM disallows this, throws error
    - Glob patterns for entity paths (`"./src/**/*.entity.ts"`) - breaks with bundlers
    - Initializing relation arrays with `= []` - causes TypeORM to detach all existing relations on save
    
    **Common Mistakes:**
    
    - Expecting eager relations to work with QueryBuilder - eager only works with `find*` methods, use `leftJoinAndSelect` instead
    - Using `@BeforeUpdate`/`@AfterUpdate` with `update()` - listeners only fire with `save()`, not `update()`/`insert()`
    - Forgetting `reflect-metadata` import at app entry point - decorators silently fail
    - Using `limit()`/`offset()` with joins in QueryBuilder - returns wrong results; use `take()`/`skip()` instead
    - Not exposing FK column (e.g., `authorId`) alongside relation - forces a join for simple lookups
    
    **Gotchas & Edge Cases:**
    
    - `save()` returns the saved entity but reloads it from DB - the returned object may differ from input
    - `update()` and `delete()` return `UpdateResult`/`DeleteResult` with affected count, not the entity
    - `findOne({ where: {} })` with empty where returns the first row, not null - always provide conditions
    - Enum changes in entity require a migration - database enum types don't auto-update
    - `@Column({ select: false })` excludes column from default SELECTs - must explicitly select with QueryBuilder
    - Lazy relations require `Promise<T>` type on the property - not intuitive for JS/TS developers
    - `cascade: true` can save unintended nested objects - be explicit with `cascade: ["insert"]` or `cascade: ["update"]`
    - Transaction isolation varies by database driver - not all levels available on all databases
    - `QueryRunner` must be released even on success - failure to release leaks connections until pool exhaustion
    
    </red_flags>
    
    ---
    
    <critical_reminders>
    
    ## CRITICAL REMINDERS
    
    > **All code must follow project conventions in CLAUDE.md**
    
    **(You MUST NEVER use `synchronize: true` in production - it can drop columns and lose data when entities change)**
    
    **(You MUST use `insert()`/`update()` instead of `save()` when the operation type is known - `save()` always runs an extra SELECT query)**
    
    **(You MUST use the provided transaction `manager` parameter or `queryRunner.manager` inside transactions - NEVER use the global entity manager or repository)**
    
    **(You MUST define relations with explicit `@JoinColumn()` on the owning side of `@OneToOne` and optionally `@ManyToOne`, and `@JoinTable()` on one side of `@ManyToMany`)**
    
    **Failure to follow these rules will cause data loss from schema sync, doubled query counts from unnecessary SELECTs, broken transaction atomicity, and connection pool exhaustion.**
    
    </critical_reminders>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related