Skip to content

Data & TypeORM Guidelines

This is the canonical engineering guideline for working with the persistence layer of the Mr. Mentor / MAS backend. It covers the TypeORM DataSource singleton and its connection pool, the very important implications of running with synchronize: true in this codebase, entity-authoring conventions (primary keys, timestamps, enums, relations, cascades, indexes), repository and transaction patterns, entity subscribers / lifecycle hooks, and database seeding. Read this before adding or changing any entity — schema changes here apply to a shared, multi-product PostgreSQL instance with auto-sync turned on.

Status: documented from source on this branch.


Overview

Every product in this monorepo backend (Mr. Mentor mentorship, Mr. Hire recruitment, MAS LMS, Sales CRM, Finance, the AI platform, the workflow engine, the vendor API platform) persists to one PostgreSQL database through a single shared TypeORM DataSource. There is no per-domain database; isolation is achieved with Postgres schemas (public, mas_crm, mrlearn, mrtest, superadmin) and naming conventions rather than separate connections.

  • Who uses this layer: every controller, service, worker, socket handler and seed script. Services receive (or fetch) the DataSource and call getRepository(Entity).
  • Where it sits: the DatabaseService singleton (src/config/database.ts) is initialized first during app startup (src/index.ts), before Redis, BullMQ workers, and the HTTP / Socket.IO server. Nothing else can run until the DataSource is connected and (auto-)synced.
  • Key fact that dominates everything below: synchronize: true is hard-coded on. On every boot, TypeORM diffs the entity metadata against the live schema and issues ALTER/CREATE statements automatically. There are no migration files in normal operation. This is fast for development but is the single largest source of operational risk in this backend.

Defined entity count is large (the entities array in src/config/database.ts lists well over 130 classes spanning all products). Treat the entity list in that file as the source of truth: an entity is only synced if it is both imported and added to the entities array.


Key concepts & entities

Term Meaning
DataSource TypeORM's connection manager. One instance, wrapped by DatabaseService (src/config/database.ts).
DatabaseService Singleton (getInstance()) owning the DataSource; exposes initialize(), close(), isConnected().
synchronize: true TypeORM auto-applies entity → schema diffs on connect. ON in this repo for all environments.
Repository dataSource.getRepository(Entity) — the primary data-access API used across services.
Entity A @Entity('table_name')-decorated class in src/entities/. Maps 1:1 to a Postgres table.
Subscriber An EntitySubscriberInterface reacting to lifecycle events (afterInsert, etc.). Registered in the subscribers array.
Named schema A non-public Postgres schema (mas_crm, mrlearn, mrtest, superadmin). Must be pre-created — synchronize does not create schemas, only tables.
Pool The pg connection pool: min: 5, max: 20, 30s idle timeout, 10s connect timeout.

Representative entities to study as templates:

  • src/entities/User.ts — UUID PK, enums (UserRole, UserStage), self-referencing ManyToOne (salesHead), select: false on password, manual timestamp columns, @Index.
  • src/entities/Slots.ts — two ManyToOne to User with onDelete: 'CASCADE', an enum status (MeetingStatus), OneToOne/OneToMany inverse relations.
  • src/entities/TokenUsage.ts — ledger row pattern (balanceBefore/balanceAfter), onDelete: 'SET NULL' for a nullable FK.
  • src/entities/DiscountRequest.ts — multiple named @Index, decimal money columns, documented status lifecycle in a comment block.
  • src/entities/Mas101PapWorkflow.ts@CreateDateColumn/@UpdateDateColumn, a unique composite-style @Index, and a rich status enum.

Architecture

How a request reaches the database through the shared DataSource.

flowchart TD
    REQ["HTTP request / Socket event / BullMQ job"] --> CTRL["Controller (src/controllers)"]
    CTRL --> SVC["Service (src/services)"]
    SVC -->|"getRepository(Entity)"| REPO["TypeORM Repository"]
    SVC -->|"dataSource.transaction(...)"| TX["Transactional EntityManager"]
    REPO --> DS["DataSource singleton (DatabaseService)"]
    TX --> DS
    DS --> POOL["pg connection pool (min 5 / max 20)"]
    POOL --> PG[("PostgreSQL")]

    subgraph SUB["Entity lifecycle"]
        SUBS["Subscribers (UserSubscriber, ApplicationSubscriber)"]
    end
    DS -. "fires events" .-> SUBS
    SUBS -->|"event.manager"| PG

    subgraph SCHEMAS["Postgres schemas (one DB)"]
        PUB["public"]
        CRM["mas_crm"]
        ML["mrlearn"]
        MT["mrtest"]
        SA["superadmin"]
    end
    PG --- SCHEMAS

    BOOT["App startup (src/index.ts)"] -->|"initialize() then synchronize"| DS
    BOOT --> SEED["Seed scripts (src/seeding)"]
    SEED --> DS

Data model

The persistence layer itself does not "own" a domain table set; instead it is the substrate for every domain. Below is an illustrative ER snippet using a few real entities to show the conventions you should follow (UUID PKs, FK columns paired with relation objects, enums for status). For full per-domain models see data-model.md.

erDiagram
    USER ||--o| TOKEN : "owns balance"
    USER ||--o{ TOKEN_USAGE : "ledger of"
    USER ||--o{ SLOT : "mentors"
    USER ||--o{ SLOT : "books as student"
    SLOT ||--o{ TOKEN_USAGE : "consumed by"
    USER ||--o| MENTOR : "expert profile"
    APPLICATION ||--o{ DISCOUNT_REQUEST : "has"
    USER ||--o{ USER : "salesHead of"

    USER {
        uuid id PK
        string email UK
        string password "select false"
        enum role "UserRole"
        enum stage "UserStage"
        uuid salesHeadId FK
        timestamp createdAt
        timestamp updatedAt
    }
    SLOT {
        uuid id PK
        uuid mentorId FK
        uuid studentId FK
        enum status "MeetingStatus"
        bool isAvailable
        bool earningsCredited
    }
    TOKEN_USAGE {
        uuid id PK
        uuid userId FK
        uuid slotId FK
        enum usageType "TokenUsageType"
        int tokensUsed
        int balanceBefore
        int balanceAfter
    }
    DISCOUNT_REQUEST {
        uuid id PK
        uuid applicationId FK
        uuid batchId FK
        decimal requestedPrice
        decimal approvedPrice
        enum status "DiscountRequestStatus"
    }

Notable enum/status patterns (always model status as a TypeScript enum mapped to a Postgres enum column with a default):

  • MeetingStatus in src/entities/Slots.ts (tentative, confirmed, completed, ...).
  • TokenUsageType in src/entities/TokenUsage.ts (meeting_booking, penalty, refund, ...).
  • DiscountRequestStatus in src/entities/DiscountRequest.ts (pending/approved/rejected).
  • Mas101PapWorkflowStatus and friends in src/entities/Mas101PapWorkflow.ts.

API surface

This is an internal data layer, not an HTTP surface — there are no routes that belong to it. The relevant "API" is the TypeORM DataSource / Repository API plus the DatabaseService helpers:

Symbol Where Purpose
DatabaseService.getInstance() src/config/database.ts Get the singleton owning the DataSource.
.initialize() DatabaseService Ensure named schemas, pre-sync data fixes, then connect + auto-sync.
.dataSource.getRepository(Entity) TypeORM Primary data-access handle used in services.
.dataSource.transaction(cb) TypeORM Run multiple writes atomically.
.dataSource.createQueryRunner() TypeORM Manual transaction / raw SQL with explicit control.
.isConnected() / .close() DatabaseService Health checks and graceful shutdown.

Operational-facing endpoints that touch this layer indirectly: /api/health (connection liveness) and /api/redis/* (cache admin). Those belong to other docs.


User journeys

Journey 1 — App startup, schema bootstrap, and auto-sync

The most consequential journey: how the schema is brought up to match the entities on every boot. Note the deliberate ordering — named schemas and a pre-sync data fix must run before synchronize so the auto-diff does not fail.

sequenceDiagram
    participant Boot as src/index.ts
    participant DB as DatabaseService
    participant PG as PostgreSQL
    participant Seed as Seed scripts

    Boot->>DB: initialize
    DB->>PG: ensureNamedSchemas for mrlearn mrtest mas_crm superadmin
    Note over DB,PG: synchronize creates tables only not schemas so they must exist first
    DB->>PG: migrateSuperadminRoleBeforeSync updates superadmin rows to admin
    Note over DB,PG: enum recreate would fail on stale label so fix data first
    DB->>PG: dataSource.initialize triggers schema auto-sync
    PG-->>DB: schema diffed and ALTER or CREATE applied
    DB->>PG: migrateLegacyRawLeadsToMasCrm one-shot idempotent copy
    DB-->>Boot: connection established
    Boot->>Seed: seedColleges seedUsers seedJobTemplates and more
    Seed->>PG: insert if missing
    Boot->>Boot: init Redis then workers then HTTP and Socket.IO

Journey 2 — A service reads and writes through a repository

The everyday path. A service obtains a repository from the shared DataSource and performs reads/writes; the connection pool hands out and recycles connections automatically.

sequenceDiagram
    participant Ctrl as Controller
    participant Svc as Service
    participant Repo as Repository
    participant Pool as pg pool
    participant PG as PostgreSQL

    Ctrl->>Svc: call method with validated input
    Svc->>Repo: getRepository Entity from dataSource
    Svc->>Repo: findOne with where clause
    Repo->>Pool: acquire connection
    Pool->>PG: SELECT
    PG-->>Pool: row
    Pool-->>Repo: result then release connection
    Repo-->>Svc: entity or null
    Svc->>Repo: save updated entity
    Repo->>PG: INSERT or UPDATE
    PG-->>Repo: persisted row
    Svc-->>Ctrl: return DTO

Journey 3 — Atomic multi-write with a transaction

When several rows must change together (for example a token deduction plus a ledger row plus a slot booking), wrap them in dataSource.transaction so partial failures roll back. This is the preferred pattern over manual query runners for most cases.

sequenceDiagram
    participant Svc as Service
    participant DS as DataSource
    participant TX as Transactional manager
    participant PG as PostgreSQL

    Svc->>DS: transaction with async callback
    DS->>TX: open transaction and BEGIN
    Svc->>TX: getRepository User then update balance
    TX->>PG: UPDATE users
    Svc->>TX: getRepository TokenUsage then insert ledger row
    TX->>PG: INSERT token_usage
    alt all writes succeed
        DS->>PG: COMMIT
        DS-->>Svc: callback result
    else any write throws
        DS->>PG: ROLLBACK
        DS-->>Svc: error propagated
    end

Journey 4 — Manual query runner for fine-grained control

For batch imports and raw SQL (for example the legacy CRM migration in database.ts, or createQueryRunner usage in services like TokenAllotmentRequestService), use a query runner to control connect, transaction boundaries, and release explicitly.

sequenceDiagram
    participant Svc as Service
    participant QR as QueryRunner
    participant PG as PostgreSQL

    Svc->>QR: createQueryRunner
    Svc->>QR: connect
    Svc->>QR: startTransaction
    Svc->>QR: query raw SQL or manager save
    QR->>PG: execute statements
    alt success
        Svc->>QR: commitTransaction
    else failure
        Svc->>QR: rollbackTransaction
        Note over Svc,QR: rethrow after rollback so caller sees the error
    end
    Svc->>QR: release always in finally

Journey 5 — Entity subscriber side-effects on insert

Some cross-entity invariants are enforced by subscribers rather than service code. When a User is inserted, UserSubscriber ensures a zero-balance Token row exists. When an Application is inserted, ApplicationSubscriber auto-creates a CRM Lead. Subscribers run inside the same transaction as the triggering write and must use event.manager, not a fresh repository, to stay transactional.

sequenceDiagram
    participant Svc as Service
    participant Repo as User repository
    participant DS as DataSource
    participant Sub as UserSubscriber
    participant PG as PostgreSQL

    Svc->>Repo: save new User
    Repo->>PG: INSERT users
    DS->>Sub: afterInsert event with entity and manager
    Sub->>PG: find Token for userId using event.manager
    alt no token exists
        Sub->>PG: insert Token with balance 0
    else token exists
        Note over Sub: no-op
    end
    Sub-->>DS: resolve
    DS-->>Svc: save completes

Background jobs & async

The data layer is not itself a queue, but several async paths write through the shared DataSource:

  • BullMQ workers (src/workers/*) — database.worker, cleanup.worker, kpi.worker, email.worker, resumeAnalysis.worker — all use the same singleton DataSource. They run in the same process, so they share the min 5 / max 20 pool; long-running heavy jobs can starve the pool. See background-jobs-and-queues.md.
  • Cleanup job (every 24h) removes expired slots — a bulk write path; prefer query builder or query runner with batching over loading every row.
  • KPI job (every 15min) aggregates dashboard metrics — read-heavy; use aggregate SQL, not N+1 entity loads.
  • External sync runs (MrLearnSyncService, MrTestSyncService) read the DataSource via DatabaseService.getInstance().dataSource and write into the dedicated mrlearn / mrtest schemas.

There is no migration runner job. The migrations array in database.ts is populated only if a migrations/ directory exists (it does not in normal dev), and synchronize: true means migrations are never the active mechanism.


External integrations

Concern Detail
PostgreSQL Single instance. Connection via DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME. Defaults (shubham/template) are dev-only fallbacks — production sets all five.
Connection pool extra: { max: 20, min: 5, idleTimeoutMillis: 30000, connectionTimeoutMillis: 10000 }. Tune max only with awareness of Postgres max_connections across all app instances + workers.
SSL Currently commented out (ssl: { rejectUnauthorized: false }). Enable per environment if the DB requires TLS.
pg raw client database.ts opens short-lived raw pg.Client connections for ensureNamedSchemas and the pre-sync superadmin role fix, because these must run before the synced DataSource connects.
Logging logging: false. Turn on selectively when debugging query issues; it is verbose.

There are no feature flags governing the data layer itself, but several domains key off rows in config tables (SystemConfig, AiScreeningConfig, etc.) rather than env vars.


Status lifecycles

synchronize-managed enums are how status is modeled. The example below is the DiscountRequestStatus lifecycle from src/entities/DiscountRequest.ts — a typical approval-flow enum. Note the gotcha it implies: removing an enum label while rows still hold it makes the auto-sync enum-recreate fail (the same hazard the startup superadmin-role migration guards against for UserRole).

stateDiagram-v2
    [*] --> pending : sales creates request
    pending --> approved : admin approves and sets approvedPrice
    pending --> rejected : admin rejects with note
    rejected --> pending : sales retries with new request
    approved --> [*] : Application finalAmount updated
    rejected --> [*]

A second example — the meeting/slot status enum (MeetingStatus in src/entities/Slots.ts):

stateDiagram-v2
    [*] --> tentative : slot created
    tentative --> meeting_requested : student requests
    meeting_requested --> under_review : pending approval
    under_review --> confirmed : approved
    tentative --> confirmed : direct booking
    confirmed --> cancellation_requested : either party requests cancel
    cancellation_requested --> cancelled : approved
    confirmed --> completed : meeting finished
    completed --> [*]
    cancelled --> [*]

Entity anatomy — annotated reference

Use this as the template for new entities. It encodes every convention this codebase follows.

// src/entities/Example.ts
import {
  Entity, PrimaryGeneratedColumn, Column,
  ManyToOne, OneToMany, JoinColumn, Index,
  CreateDateColumn, UpdateDateColumn,
} from 'typeorm';
import { User } from './User';

// 1. Status is ALWAYS a TS enum mapped to a Postgres enum column with a default.
export enum ExampleStatus {
  PENDING = 'pending',
  ACTIVE = 'active',
  CLOSED = 'closed',
}

// 2. Declare named indexes at class level for columns you filter/join on a lot.
@Index('IDX_EXAMPLE_OWNER', ['ownerId'])
@Index('IDX_EXAMPLE_STATUS', ['status'])
@Entity('examples')              // 3. Explicit snake_case-ish table name, plural.
export class Example {
  // 4. UUID primary key is the house standard (NOT auto-increment int).
  @PrimaryGeneratedColumn('uuid')
  id: string;

  // 5. Always specify column type, length for varchar, and nullability explicitly.
  @Column({ type: 'varchar', length: 255 })
  title: string;

  @Column({ type: 'text', nullable: true })
  description?: string;

  // 6. Money: decimal with explicit precision/scale, never float.
  @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
  amount?: number;

  // 7. Enum column with a default so existing rows backfill cleanly under auto-sync.
  @Column({ type: 'enum', enum: ExampleStatus, default: ExampleStatus.PENDING })
  status: ExampleStatus;

  // 8. FK pattern: a raw scalar column PLUS the relation object on the same name.
  //    The scalar lets you set/read the id without loading the relation.
  @Column({ type: 'uuid' })
  ownerId: string;

  @ManyToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'ownerId' })
  owner: User;

  // 9. Inverse side of a one-to-many. No DB column; lazy/eager off by default.
  @OneToMany(() => Example, (e) => e.owner)
  children: Example[];

  // 10. Timestamps: prefer the decorator columns for new entities. (Legacy
  //     entities use manual CURRENT_TIMESTAMP columns — both are acceptable,
  //     but be consistent within an entity.)
  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

Two timestamp styles coexist in the codebase — know both:

// Legacy/manual style (e.g. User.ts, TokenUsage.ts)
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
updatedAt: Date;

// Decorator style (e.g. Mas101PapWorkflow.ts) — preferred for new code
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date;

Other conventions observed in real entities:

  • Sensitive columns use select: false (e.g. User.password) so they are excluded from default find results; load them explicitly with addSelect when needed.
  • Cascade choices are intentional: onDelete: 'CASCADE' for owned children (SlotsUser), onDelete: 'SET NULL' for optional references that should survive parent deletion (TokenUsage.slotId).
  • Soft deletes are NOT used anywhere in this codebase — there are no @DeleteDateColumn columns. Deletions are hard. If you need recoverable deletes, add an explicit isActive / status flag (as User.isActive does) rather than introducing soft-delete semantics.
  • jsonb columns are used for flexible/extra data (extraData, config blobs, badge metadata). Type them as { type: 'jsonb', nullable: true } and give them a TS interface.
  • Self-referencing relations are allowed (User.salesHeadUser); always make the FK column nullable.

Do / Don't — schema changes under synchronize: true

Because synchronize auto-applies the entity → schema diff on every boot, some changes are safe and some silently destroy data. Use this table.

Change Verdict Why / How
Add a new nullable column DO Pure additive ALTER ADD COLUMN. Safe.
Add a column with a default DO Existing rows backfill with the default. Safe.
Add a column NOT NULL without default to a populated table DON'T Sync will fail or lock; add as nullable first, backfill, then tighten.
Add a new entity (and register it in the entities array) DO Creates the table. Must be added to the array in database.ts or it is ignored.
Add a new value to an enum DO (carefully) Postgres enum gets the new label. Order matters for some tools; append.
Remove an enum value while rows still use it DON'T Enum recreate cast fails. Migrate rows off the label first (see migrateSuperadminRoleBeforeSync).
Rename a column or entity property DON'T (directly) TypeORM sees it as drop-old + add-new = data loss. Add new, copy data, then remove old in a later deploy.
Change a column type (e.g. varcharint) DON'T (directly) Auto-cast may fail or truncate. Add a new column, migrate, swap.
Narrow a column (length, precision) DON'T Can truncate or fail mid-flight. Widen-only is safe.
Add an index via @Index DO Created on next sync. For huge tables, prefer creating CONCURRENTLY by hand to avoid a write lock.
Add a non-public schema-scoped entity DO, but pre-create the schema synchronize does NOT create schemas. Add the schema name to ensureNamedSchemas in database.ts.
Drop a column / entity DON'T without a backup Sync issues DROP COLUMN / DROP TABLE immediately on the next boot. Irreversible.
Rely on synchronize in production DON'T (long-term) See recommended migration path below.

synchronize: true is appropriate for local dev but is a production hazard (uncontrolled ALTER/DROP, no review, no rollback, schema drift between environments). The recommended trajectory:

  1. Generate, don't hand-write. Set synchronize: false in a staging config, then typeorm migration:generate to capture the current diff as a reviewable file.
  2. Run migrations explicitly at deploy time (migration:run). The migrations glob is already wired in database.ts (src/migrations / migrations), so dropping files there makes them discoverable.
  3. Review every generated migration for destructive statements (DROP, type changes, NOT NULL adds) before merging.
  4. Keep dev on synchronize if desired, but never let an unreviewed schema reach prod.

Until that path is adopted, treat the Do/Don't table as mandatory and always back up the database before deploying an entity change, since a careless rename ships as a drop on the next boot.


Repository, transaction & query patterns

  • Get a repository: const repo = dataSource.getRepository(Entity). Services typically cache these in the constructor (see TokenAllotmentRequestService). 100+ services follow this pattern — do not instantiate raw SQL clients for ordinary CRUD.
  • Reads: prefer findOne({ where }) / find({ where, relations }). For aggregates, reports, and anything performance-sensitive, use createQueryBuilder instead of loading entities and counting in JS.
  • Relations: the FK scalar + relation-object pattern lets you write entity.ownerId = id without loading owner. Use relations: ['owner'] (or a query builder join) only when you actually need the related object — avoid eager relations by default.
  • Transactions: use dataSource.transaction(async (manager) => { ... }) for multi-row atomic writes. Inside the callback, always use the provided manager (e.g. manager.getRepository(X)), never the global repository, or the writes escape the transaction.
  • Query runners: use dataSource.createQueryRunner() for raw SQL, bulk operations, or when you need explicit BEGIN/COMMIT/ROLLBACK control. Always release() in a finally block (the legacy CRM migration in database.ts is the canonical example).
  • Accessing the DataSource outside a service: DatabaseService.getInstance().dataSource.

Entity subscribers & lifecycle hooks

Subscribers live in src/subscribers/ and are registered in the subscribers array of the DataSource config. Only two are active:

  • UserSubscriber (src/subscribers/user.subscriber.ts) — afterInsert: creates a zero-balance Token for every new User if one does not already exist. (It also contains commented-out logic to auto-create a Mentor profile for EXPERT users.)
  • ApplicationSubscriber (src/subscribers/application.subscriber.ts) — afterInsert: auto-creates a CRM Lead (source my-analytics-school, status ACTIVE) for the application's user, unless a matching lead already exists.

Rules for writing subscribers:

  1. Implement listenTo() returning the entity class so the subscriber is scoped.
  2. Always use event.manager for any DB access inside a hook so the work joins the triggering write's transaction. Using a fresh repository breaks atomicity.
  3. Make hooks idempotent — both existing subscribers check for an existing row before inserting, because the same insert path can run more than once.
  4. Keep hooks fast and side-effect-light. Heavy work (emails, external calls) belongs on a BullMQ queue, not in a subscriber that blocks the write.
  5. Prefer explicit service logic over subscribers for anything a reader needs to discover — subscriber side-effects are easy to miss.

Seeding

Seed scripts live in src/seeding/ and run at startup from src/index.ts. They fall into two groups:

  • Idempotent "ensure" seeds run on every bootseedColleges, seedUsers, seedJobTemplates, seedGlobalScreeningConfig, seedWorkflowDefaults, seedApplicationLeads, seedLeadTags, ensureSalesHeadProfiles, ensureAdminUser. These insert-if-missing and are safe to re-run.
  • Heavy/destructive seeds gated behind ENABLE_SEEDING=trueseedBatches, seedCourses, and the standalone seed.ts (run via npm run seed). seed.ts truncates tokens, mentors, and users (TRUNCATE ... CASCADE) before loading JSON fixtures — never run it against a database you care about.

Seed conventions:

  • Each seed takes a DataSource and uses getRepository.
  • JSON fixtures (batches.json, studentData.json, top-indian-colleges.json, updated_mentors_smart.json) live alongside the scripts and are loaded with a guarded loadSeedJson helper.
  • One-shot data migrations (e.g. backfillLeadOwnership, campaignLeadBackfill) are written as idempotent scripts, not as schema migrations.
  • npm entry points: npm run seed, npm run seed:test-meeting, npm run seed-sales.

Edge cases, limits & gotchas

  • Auto-sync is the elephant. Any property rename or type change ships as drop+recreate on the next boot — silent data loss. Re-read the Do/Don't table before changing a column.
  • An entity must be in the entities array. Importing the file is not enough; if it is not listed in database.ts it is never synced and its table never appears.
  • Named schemas are not created by sync. New schema-scoped entities (mrlearn, mrtest, mas_crm, superadmin) require the schema to be pre-created in ensureNamedSchemas.
  • Enum value removal breaks boot. The startup migrateSuperadminRoleBeforeSync exists precisely because removing the superadmin label from UserRole would make the enum recreate fail on rows still carrying it. Migrate data off a label before you delete the label.
  • Shared pool across workers. All 5 BullMQ workers, HTTP handlers, and socket handlers share the same min 5 / max 20 pool in one process. A long transaction or a leaked query runner (missing release()) starves everyone — always release runners in finally.
  • No soft deletes. Deletes are permanent. Use status/isActive flags for recoverable state.
  • logging: false. You will not see SQL by default; flip it on locally to debug.
  • Multi-platform (x-platform header). The DB is shared across products; rows are distinguished by source/platform columns and schemas, not by separate connections. Scope queries by the relevant ownership/platform column — never assume a table belongs to one product.
  • select: false columns (like User.password) are absent from default finds; you must addSelect them explicitly, which is easy to forget when debugging auth.
  • Decimal columns return as strings from pg in some configurations — coerce money values with care when doing arithmetic.