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).
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:
@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— setname: 'first_name'; enforced globally bySnakeNamingStrategy. implementsits*.types.tsinterface — class and shared type stay in lockstep. See Data Modeling.- Response DTOs
extends DatabaseEntityDtoto inheritid+ 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 offindOne/findAllautomatically. hardDelete,hardDeleteMany,hardDeleteWherepermanently erase — rare, e.g. a compliance request.
The repository
A concrete repository is four lines: extend BaseRepository<T>, pass the entity to super.
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:
| Family | Methods |
|---|---|
| Reads | findAll, findOne, findOneWhere, findById, findAndCount, count, exists |
| Writes | create, createMany, update, updateWhere, save, saveMany, upsert |
| Delete | softDelete*, hardDelete, hardDeleteMany, hardDeleteWhere |
| Query | createQueryBuilder, 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:
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: falseeverywhere — TypeORM never alters the live schema. Structural changes go through migrations only.autoLoadEntities— no manual entity registration.SnakeNamingStrategy— thefirstName↔first_namemapping 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:
| Command | What it does |
|---|---|
pnpm migration:generate [name] | Diffs entities against the DB and writes a new migration |
pnpm migration:run | Applies pending migrations |
pnpm migration:revert | Rolls 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
Error Handling & Logging
How services throw HttpExceptions, how the global filter shapes them into one error envelope, and what the request logger records.
Auth & Permissions
How the server locks down every route by default — four global guards, handler decorators, CASL, and the JWT that carries your school.