Naalya Handbook

The Database

The DatabaseEntity base, the fail-closed tenant-aware BaseRepository, soft delete, snake_case columns, and the migration workflow.

Data layer is TypeORM over PostgreSQL. Tables are entity classes; rows are read/written through repositories. Two base classes in @app/shared do the heavy lifting — every table extends them.

The base entity

Every table extends DatabaseEntity — a UUID id plus declarations for the three timestamp fields (real columns added separately).

libs/shared/src/database/database.entity.ts
abstract class DatabaseEntity {
  @PrimaryGeneratedColumn('uuid') id: string;
  createdAt: Date;   // declared only — registered by @WithTimestamps()
  updatedAt: Date;
  deletedAt?: Date;  // soft-delete marker
}

Pair it with @WithTimestamps(), which registers created_at / updated_at / deleted_at last, after your own columns:

apps/.../staff-profile.entity.ts
@Entity('staff_profile')
@WithTimestamps()
export class StaffProfileEntity extends DatabaseEntity implements StaffProfile {
  @Column({ name: 'first_name' }) firstName: string;
}

Conventions on every entity:

  • Columns are snake_case — set name: 'first_name'; enforced globally by SnakeNamingStrategy.
  • implements its *.types.ts interface — class and shared type stay in lockstep. See Data Modeling.
  • Response DTOs extends DatabaseEntityDto to inherit id + timestamps.

DatabaseEntity only declares timestamps. Extend the base without @WithTimestamps() and the entity compiles but has no timestamp columns. Always apply both.

Soft delete

deletedAt is a TypeORM DeleteDateColumn, so deletes are soft by default: the row stays, deleted_at is stamped, and TypeORM hides it from ordinary queries — no manual WHERE deleted_at IS NULL.

  • Use the repository's softDelete* methods; deleted rows drop out of findOne/findAll automatically.
  • hardDelete, hardDeleteMany, hardDeleteWhere permanently erase — rare, e.g. a compliance request.

The repository

A concrete repository is four lines: extend BaseRepository<T>, pass the entity to super.

apps/.../staff-profile.repository.ts
export class StaffProfileRepository extends BaseRepository<StaffProfileEntity> {
  constructor() {
    super(StaffProfileEntity);
  }
}

Repositories stay thin — no business logic (that lives in services). You inherit the TypeORM surface as methods:

FamilyMethods
ReadsfindAll, findOne, findOneWhere, findById, findAndCount, count, exists
Writescreate, createMany, update, updateWhere, save, saveMany, upsert
DeletesoftDelete*, hardDelete, hardDeleteMany, hardDeleteWhere
QuerycreateQueryBuilder, cursorPaginate

BaseRepository auto-scopes every read and write by tenant and is fail-closed — a scoped table touched with no school context throws rather than leaking. The full engine (inject/bypass/fail modes, runUnscoped / withSchool escape hatches) lives in Multi-Tenancy. It scopes by tenant only: the academic year is explicit request data on year-owned entities — see Academic-Year Scoping.

For list reads, use cursorPaginate — see Querying & Pagination.

New service-level collection methods are named list, not findAll. findAll stays on the repository for compatibility.

TypeORM configuration

DatabaseModule wires TypeORM from config. Two settings are load-bearing:

libs/shared/src/database/database.module.ts
TypeOrmModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    type: 'postgres',
    url: config.get('database.url'),
    autoLoadEntities: true,        // entities discovered, never hand-registered
    synchronize: false,            // never auto-sync — migrations only
    namingStrategy: new SnakeNamingStrategy(),
  }),
});
  • synchronize: false everywhere — TypeORM never alters the live schema. Structural changes go through migrations only.
  • autoLoadEntities — no manual entity registration.
  • SnakeNamingStrategy — the firstNamefirst_name mapping across the schema.

The CLI uses a separate DataSource, typeorm.config.cli.ts (loads .env.local, scans apps/*/src/app/**/*.entity.ts, reads/writes database/migrations/). Staging/prod have their own variants.

One escape valve: an entity with columns TypeORM can't express — knowledge_base_chunk's pgvector embedding and tsvector column — declares @Entity('...', { synchronize: false }) so migration:generate skips it entirely (no spurious DROPs); its schema lives in hand-written raw-SQL migrations, and CI runs a pgvector/pgvector:pg16 Postgres so those migrations apply.

synchronize: true rewrites the DB to match entities on boot and can silently drop columns and data. Keep it false.

Migrations

A migration is a versioned, reviewable schema-change script. Because synchronize is off, migrations are the only way the structure changes. They live in database/migrations/, are app-agnostic, and run with:

CommandWhat it does
pnpm migration:generate [name]Diffs entities against the DB and writes a new migration
pnpm migration:runApplies pending migrations
pnpm migration:revertRolls back the last migration

Each has :staging and :prod variants targeting the matching DataSource. With no name, generation auto-names the file <adjective>_<noun>_<timestamp>.

A coding agent never runs migration:generate or migration:run — those touch a real database. The agent edits the entity and hands off; the developer reviews, generates, inspects the SQL, and runs it. See the Add an Entity & Migration recipe.

Where to go next

On this page