Part 2 of a series building "Grimoire API" — a choose-your-own-adventure backend in NestJS, learned one milestone at a time. Part 1 covered controllers, providers, DI and validation with a single read-only endpoint. This time: making a player's progress survive a server restart.
The problem with Part 1
By the end of Part 1, GET /pages/:id worked — but every request started from scratch. There was no concept of "a player," let alone where they currently were in the story. To move a player from one page to the next, something has to remember that between requests. That's what a database is for.
Picking the pieces
For this project I'm using PostgreSQL with TypeORM — NestJS's most common ORM pairing, well documented, and closely integrated with NestJS's dependency injection (you @InjectRepository() a repository the same way you inject a service).
I'm running Postgres locally through Docker Compose rather than installing it directly — one docker compose up and it's there, one docker compose down and it's gone, no leftover system service to remember about.
The entity
An entity is a TypeORM class that maps to a database table. Here's PlayerProgress:
// src/progress/entities/player-progress.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity()
export class PlayerProgress {
@PrimaryGeneratedColumn('uuid')
id: string;
@OneToOne(() => User)
@JoinColumn()
user: User;
@Column({ default: 'page-001' })
currentPageId: string;
@Column({ default: 0 })
xp: number;
}
Two decorators do the heavy lifting: @Entity() says "this is a table," @Column() says "this is a column." @OneToOne + @JoinColumn describe the relationship to User — one player, one progress record.
Notice what's not here: no level column. The spec I wrote before touching any code was explicit about this — level is always calculated from XP, never stored as an independent value. If I stored both, sooner or later they'd drift out of sync (say, a bug that updates XP but forgets to bump level), and now the API is lying to the player about their own progress. Deriving it removes the possibility entirely — there's nothing to get out of sync with itself.
Wiring TypeORM into the module
// src/progress/progress.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlayerProgress } from './entities/player-progress.entity';
import { ProgressService } from './progress.service';
import { ProgressController } from './progress.controller';
@Module({
imports: [TypeOrmModule.forFeature([PlayerProgress])],
controllers: [ProgressController],
providers: [ProgressService],
})
export class ProgressModule {}
TypeOrmModule.forFeature([PlayerProgress]) is what makes @InjectRepository(PlayerProgress) available for injection inside this module — it's the same DI pattern from Part 1, just applied to a repository instead of a plain service.
The repository pattern
Inside the service, I don't write raw SQL — I use the repository, an object TypeORM generates for each entity with methods like findOne, save, update:
// src/progress/progress.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlayerProgress } from './entities/player-progress.entity';
@Injectable()
export class ProgressService {
constructor(
@InjectRepository(PlayerProgress)
private readonly progressRepo: Repository<PlayerProgress>,
) {}
async findForUser(userId: string): Promise<PlayerProgress> {
const progress = await this.progressRepo.findOne({
where: { user: { id: userId } },
});
if (!progress) {
throw new NotFoundException('No progress found for this player');
}
return progress;
}
async advance(userId: string, nextPageId: string): Promise<PlayerProgress> {
const progress = await this.findForUser(userId);
progress.currentPageId = nextPageId;
return this.progressRepo.save(progress);
}
}
Everything is async now — talking to a database is I/O, and NestJS (like the rest of Node) expects that to be handled with promises, not callbacks.
A temporary shortcut: no auth yet
This project's milestones are deliberately ordered so authentication (Part 4) comes after persistence. That leaves an awkward gap right now: advance() needs a userId, but there's no login system yet to produce one.
For this milestone, I'm sidestepping it with a single hardcoded "default player" seeded at startup, and the endpoint doesn't take a userId at all:
@Post('choice')
async advance(@Body() dto: AdvanceProgressDto) {
return this.progressService.advance(DEFAULT_PLAYER_ID, dto.nextPageId);
}
It's not a design I'd keep — it's a conscious, temporary simplification so I can build and test persistence in isolation, without also having to build auth at the same time. Part 4 removes DEFAULT_PLAYER_ID entirely and replaces it with whichever user the JWT identifies. Calling out a shortcut like this in the code (and in this post) matters — future-me needs to know it's not the final shape.
Migrations vs. synchronize: true
NestJS's TypeORM starter examples often show synchronize: true, which auto-updates your database schema to match your entities. Convenient for a demo, dangerous for anything else — it can drop columns based on nothing but code changes, no history, no way to review what's about to happen.
Instead I generated a real migration:
npx typeorm migration:generate src/migrations/CreatePlayerProgress -d src/data-source.ts
npx typeorm migration:run -d src/data-source.ts
This produces a plain TypeScript file with up()/down() methods — reviewable in a PR, reversible, and it's the same workflow I'd use against a real production database later.
What's next
Progress now survives a restart, but the actual game rules — how much XP a choice grants, when a level-up happens, when a badge unlocks — are still just sitting inert on the choices in the JSON content. Part 3 pulls that logic out into dedicated services.
Top comments (0)