Automatic history logging for NestJS and TypeORM. You get a clear record of who changed what, when, and what it looked like before; even when you use QueryBuilder or bulk updates that ordinary TypeORM subscribers miss. Zero config to start; extend with custom entities and options when you're ready.
Jump to any part of the journey below:
- Prerequisites
- Installation
- Quick Start (3 steps)
- Advanced Configuration (The 3 Tiers)
- Advanced Features
- Retrieving & Displaying Data
- Why This Library Exists
- What Actually Happens
- Core Components
- API Reference
- Troubleshooting
- Development & Testing
- Contributing & License
Before we start, here's what you'll need:
- Node.js 18+ (LTS recommended)
- NestJS 8+
- TypeORM 0.3+
- nestjs-cls 3+ (for request-scoped context)
npm (v7+), pnpm, and Bun install peer dependencies automatically. If you use Yarn v1, install the peers yourself when you see warnings.
Step one: add the package with your preferred manager:
# npm
npm install nestjs-typeorm-history-log
# pnpm
pnpm add nestjs-typeorm-history-log
# yarn
yarn add nestjs-typeorm-history-log
# Bun
bun add nestjs-typeorm-history-logYour first steps. Follow these three steps and you'll have a working history log with no extra config. Perfect if you're new to the library or want to see it run before diving deeper.
Step 1 β Register the module in your AppModule:
import { HistoryModule } from 'nestjs-typeorm-history-log';
@Module({
imports: [HistoryModule.forRoot()],
})
export class AppModule {}Step 2 β Mark the entities you want to track. Give each entity a stable entityKey: a string that identifies this entity in history and stays the same across your app (e.g. 'project-entity').
import { EntityHistoryTracker } from 'nestjs-typeorm-history-log';
@Entity('projects')
@EntityHistoryTracker({ entityKey: 'project-entity' })
export class Project {
@PrimaryGeneratedColumn() id: number;
// ...
}Step 3 β Attach request context on routes that change data so the library knows who made the change:
import { HistoryContext } from 'nestjs-typeorm-history-log';
@Patch(':id')
@HistoryContext({ entityKey: 'project' }) // uses :id from params
update(@Param('id') id: string) { /* ... */ }Important: The library requires a user id for every history row. If it can't find one, it throws. On HTTP routes, use @HistoryContext and ensure your auth sets request.user (or the key you configure). For background jobs, cron, or any non-HTTP path that performs tracked changes, either pass a context when you call saveLog manually (e.g. context: { user_id, ... }) or run that code inside HistoryHelper.ignore(callback) so no log is written and no error is thrown. To change where the user is read from on the request, use userRequestKey, userIdField, and optionally userEntity in forRoot() β see HistoryModule.forRoot options in the API Reference.
When you're ready to make it yours. The library offers three levels: zero-config (what you have now), extending with your own columns (e.g. IP, user-agent), or mapping to a fully custom table. Pick the tier that matches where you are; no rush, and you can move up when you need to.
Call HistoryModule.forRoot() with no options. It uses the built-in HistoryLog entity (table history_logs). If you use autoLoadEntities: true in TypeORM you're done; otherwise add HistoryLog to your entities array.
If you use autoLoadEntities: true, the library registers the entity automatically. Otherwise:
import { HistoryLog } from 'nestjs-typeorm-history-log';
TypeOrmModule.forRoot({ entities: [HistoryLog] })Extend BaseHistoryLog, add columns (e.g. ip, user_agent), and pass your class as historyLogEntity: MyHistory. Use metadataProvider(req) in forRoot() to fill those columns from the request; the keys you return must match your entity's properties.
import { BaseHistoryLog } from 'nestjs-typeorm-history-log';
@Entity()
class MyHistory extends BaseHistoryLog {
@Column() ip: string;
@Column({ name: 'user_agent', nullable: true }) user_agent: string;
}
HistoryModule.forRoot({
historyLogEntity: MyHistory,
metadataProvider: (req) => ({
ip: req.ip,
user_agent: req.headers['user-agent'],
}),
})Or add metadata per handler with HistoryHelper.addMetadata when you want different data per route or from your own logic:
// Your entity can have optional columns filled by addMetadata
@Entity()
class MyHistory extends BaseHistoryLog {
@Column({ nullable: true }) ip: string;
@Column({ name: 'reason', nullable: true }) reason: string;
}
// In a controller or service: set metadata before the change (same request)
constructor(private historyHelper: HistoryHelper<MyHistory>) {}
@Patch(':id')
@HistoryContext({ entityKey: 'project' })
update(@Param('id') id: string, @Body() dto: UpdateProjectDto, @Req() req: Request) {
this.historyHelper.addMetadata({ reason: 'Security Patch', ip: req.ip });
return this.projectService.update(id, dto);
}
// Or in a service method (inject HistoryHelper and call addMetadata before the DB write)
// this.historyHelper.addMetadata({ reason: 'Bulk import' });Use any entity and an entityMapper to turn our internal data into your table shape.
@Entity()
class CustomLogs {
@PrimaryGeneratedColumn() id: number;
@Column() event_name: string;
@Column() payload: string;
}
HistoryModule.forRoot({
historyLogEntity: CustomLogs,
entityMapper: (data) => ({
event_name: data.action,
payload: JSON.stringify(data.content)
})
})| Property | Description |
|---|---|
data.action |
'CREATE', 'UPDATE', or 'DELETE'
|
data.entityKey |
The key of the entity being modified |
data.entityId |
The primary key of the record |
data.contextEntityKey |
Parent context key |
data.contextEntityId |
Parent record ID |
data.user_id |
ID of the user who made the change |
data.content |
The diff/snapshot object |
Smart guard: If your entity doesn't extend BaseHistoryLog and patchGlobal is true, TypeScript requires entityMapper (we need to know how to map to your table). If you extend BaseHistoryLog or set patchGlobal: false, entityMapper is optional.
A function (req) => ({ ... }) that runs on every request. Whatever you return is merged into each history row for that request (e.g. IP, trace id).
What ends up in your history table. Here's the shape of the data we write.
- Storage: Internally, history is stored as a flattened diff for updates and full filtered snapshots for creations/deletions. This keeps the database lean.
-
API (Unified View): When you read data via
findAllor theHistoryMapper, the library automatically transforms this into a Unified Audit View.
Every log entry is presented as a side-by-side { old, new } object where dot-notation keys are automatically unflattened into nested objects.
| Action |
old state |
new state |
|---|---|---|
| CREATE | null |
Full initial state |
| UPDATE | State of CHANGED fields before | State of CHANGED fields after |
| DELETE | Full state at time of deletion | null |
This structure is predictable and allows your UI to simply iterate the keys of content.old and content.new to show a side-by-side diff.
| Column (DB) | Type (TypeORM) | Description |
|---|---|---|
id |
number (PK, generated) |
Primary key. |
context_entity_key |
string |
Parent context key (e.g. 'project'). |
context_entity_id |
string | number | null |
Parent record ID. |
entity_key |
string |
Tracked entity key (e.g. 'project-entity'). |
entity_id |
string | number | null |
ID of the record that was changed. |
action |
enum |
CREATE, UPDATE, DELETE. |
content |
json |
Diff or full state (see above). |
user_id |
string | number | null |
Who made the change. |
created_at |
Date |
When the log was written. |
-
Tier 1 β The default
HistoryLoghas no extra columns, so there's nowhere to store it. SkipmetadataProvideror move to Tier 2. -
Tier 2 β You use this same table but add extra columns (e.g.
ip,user_agent). Run your own migration; we write the columns above and whatever you added. -
Tier 3 β You use a different table and entity. You provide an
entityMapperthat converts our internal payload into your entity; we call it and save. Your table, your schema.
Power users and non-HTTP flows. If you use workers, cron, or raw SQL, you can still record history: use saveLog and pass a context (e.g. user_id: 0 for "system"). To skip history for a block of code, use ignore(). This section is for you.
metadataProvider in forRoot() runs for every request and fills extra columns from the request. For per-handler or per-request notes (e.g. why this change), use historyHelper.addMetadata({ ... }). It merges into the current request's context so every history row written in that request includes it. Call it before the code that does the change. Multiple calls merge (later keys overwrite earlier ones).
// In forRoot(): metadataProvider fills columns from the request (e.g. IP, user-agent)
HistoryModule.forRoot({
historyLogEntity: MyHistory,
metadataProvider: (req) => ({ ip: req.ip }),
})
// In a controller: addMetadata adds extra data for this request only (e.g. reason)
constructor(private historyHelper: HistoryHelper<HistoryLog>) {}
@Patch()
@HistoryContext({ entityKey: 'project' })
update() {
this.historyHelper.addMetadata({ reason: 'Security Patch' });
return this.service.save();
}Use saveLog when there's no HTTP request (workers, cron) or when you change data outside the normal entity flow (e.g. raw SQL) and want to record a log yourself. Pass logData (entityKey, action, oldState, payload, entityTarget), the same manager you use for the write (so it's one transaction), and context with at least user_id (e.g. 0 for "system"). The library still requires a user id or it throws.
// Example: you need manager, entityTarget, oldState, payload, and context
await this.historyHelper.saveLog({
logData: {
entityKey: 'sync-task',
action: HistoryActionType.UPDATE,
entityTarget: SomeEntity,
oldState: {},
payload: { id: 1, name: 'Synced' },
},
manager: this.dataSource.manager,
context: { user_id: 0, contextEntityKey: 'system', contextEntityId: null },
});historyHelper.ignore(async () => { ... }) runs your callback in a context where history is turned off. Nothing that happens inside (e.g. a bulk fix or migration step) gets logged. The callback can be async. Other requests are unaffected.
await this.historyHelper.ignore(async () => {
await this.repository.update(id, { noise: 'data' });
});-
@HistoryColumnExclude()on a property β Never include it (e.g. passwords, tokens). -
@HistoryColumnInclude()on a property β Always include it even if it's inignoredKeys(e.g. you ignoreupdated_atglobally but want it for one entity). -
ignoredKeysinforRoot()β List of keys to strip from history content. No default list; only what you pass is ignored.
Decorators are read from the entity's prototype. Keys that aren't in the payload are simply omitted.
Building your audit dashboard. The library doesn't just store data; it makes it easy to consume. By default, findAll provides a "Unified View" that's ready for side-by-side diffing in your UI.
The findAll method un-flattens your history data automatically.
// Controller or Service
const { items, meta } = await this.historyHelper.findAll({
entityKey: 'project',
entityId: 1,
limit: 10,
});
// items[0].content will look like:
// {
// old: { profile: { status: 'active' }, budget: 1000 },
// new: { profile: { status: 'paused' }, budget: 1500 }
// }Opt-out of unflattening: If you need the raw database format (flattened dot-notation keys), set unflatten: false.
await this.historyHelper.findAll({ unflatten: false });Because the unified view delivers two objects with the same keys (for updates), building a diff table is trivial.
// Frontend pseudocode (e.g., React/Vue)
{Object.keys(log.content.new).map(key => (
<tr key={key}>
<td>{key}</td>
<td>{JSON.stringify(log.content.old?.[key])}</td>
<td>{JSON.stringify(log.content.new?.[key])}</td>
</tr>
))}The old side of a history log represents the exact partial state needed to restore the entity to its previous values.
const log = await this.historyHelper.findAll({ entityId: 'log-123' });
const previousState = log.items[0].content.old;
if (previousState) {
await this.projectRepository.update(projectId, previousState);
}If you perform custom queries (e.g. raw TypeORM find) or process logs on the frontend, use the HistoryMapper to get the same unified view.
import { HistoryMapper } from 'nestjs-typeorm-history-log';
// In a service or even in a browser (it's a pure JS utility)
const unified = HistoryMapper.mapToUnified(rawLog);
console.log(unified.old, unified.new);A quick look under the hood. Knowing why we built this helps you decide when to use it and when to go further. Three things make reliable history logging tricky with plain TypeORM; we built this library to fix all three.
Out of the box, TypeORM subscribers see repository.save() and repository.remove(), but not manager.update(), manager.delete(), manager.insert(), or manager.upsert(). The same methods you use with QueryBuilder or bulk updates. When subscribers do run, you often get only the new state, not the old one, so you can't see what actually changed.
This library patches those methods. Before each call it stores the current request context and the operation's criteria (e.g. { id: 5 }) on the database connection. When the subscriber runs, it loads the old row(s) from the DB and builds a proper before/after snapshot. You get correct history even for QueryBuilder and bulk writes.
With .save() and .remove(), event payloads can be partial (only the columns that changed) or out of sync inside a transaction. We don't rely on the event alone: we re-query by criteria to get the full row, then merge. The log always has a consistent before/after view.
Many history-log setups store the current user in AsyncLocalStorage (e.g. nestjs-cls). When many requests run at once, one request can overwrite that store before the subscriber runs, so a change gets attributed to the wrong user. We copy the context onto the connection when the operation starts and read it from there when writing the log, so the correct user stays tied to the correct write under load.
When to use it: You need a full history log (who, what, when, and what it was before), you use QueryBuilder or bulk ops, or you care about compliance and support. When to skip it: You only use repository.save() and you're fine with partial or missing history, or you're building something throwaway.
- Enterprise NestJS Developers: Teams requiring strict, compliant audit trails for data-sensitive applications.
- TypeORM Power Users: Developers who utilize QueryBuilder and bulk updates and need reliable history tracking that standard subscribers miss.
- Rapid Development Teams: Developers seeking a "plug-and-play" solution that works out-of-the-box with minimal configuration.
How it all fits together. If you like to understand the pipeline before tweaking it, this section is for you. Here's how the pieces work at a high level.
-
Module β Registers a global NestJS module with
HistoryHelper, an interceptor, and a TypeORM subscriber. Optionally patchesEntityManager.update/delete/insert/upsert. -
Interceptor β On routes with
@HistoryContext, runs first and stores context in CLS (request-scoped async local storage: parent entity key/id, user id fromrequest.user, optional extra frommetadataProvider). - Patcher β Before each patched call, if the entity is tracked, it stores the operation criteria and a copy of the current CLS context on the QueryRunner so the right user and scope stay tied to this write even under concurrency. It clears that after the call.
-
Subscriber β Listens to insert/update/remove. For tracked entities it gets context from the QueryRunner (or CLS), loads old rows by criteria, and calls
HistoryHelper.saveLogwith old state, new state, and action. Soft-deletes (e.g.is_deletedset to true) are logged as DELETE. -
HistoryHelper.saveLog β Resolves context (manual > sealed on connection > CLS), requires
user_idor throws. Builds content: full payload for CREATE/DELETE, diff for UPDATE (viamicrodiff). Filters out keys inignoredKeysand columns marked@HistoryColumnExclude. Skips saving if an UPDATE has no changes. Writes a row to your history table in the same transaction. -
findAll β Query helper that turns filters into a TypeORM query. By default, it transforms logs into the Unified Audit View (nested
{old, new}) before returning them. Useunflatten: falseto skip this. -
HistoryMapper β Utility (and NestJS service) that encapsulates the mapping logic. Use
mapToUnified(log)for the side-by-side view, ormapToEntity(log)to get a flat snapshot of one side. - addMetadata β Merges an object into the current request's context metadata. The next log written in that request will include it.
- ignore β Runs your callback in a context where history is disabled. No log rows are written for changes inside that callback.
graph TD
A[Controller Request] --> B[HistoryContextInterceptor]
B -->|Set context in CLS| C[Service/Repository Logic]
C --> D[TypeORM EntityManager]
D -->|Intercept update/delete/insert/upsert| E[HistoryPatcher]
E -->|Attach criteria & context| F[TypeORM Events]
F --> G[HistorySubscriber]
G -->|Extract context & diff| H[HistoryHelper]
H --> I[HistoryLog Repository]
I --> J[(Database: history_logs)]
For the curious: how the pieces fit. A short reference to the main building blocksβhandy when you're debugging or designing around the library.
-
HistoryContextInterceptor β Runs before handlers that have
@HistoryContext. Picks user and parent entity id from the request, optionally runsmetadataProvider(req), and stores everything in CLS for the request. -
HistoryPatcher β On init (if
patchGlobalis true) it wrapsEntityManager.update/delete/insert/upsert. Before each call it stores the operation's criteria and a copy of the current CLS context on the QueryRunner; after the call it clears that. The subscriber can then see which rows were touched and which user/context to use. -
HistoryCriteriaCarrier β Holds the "sealed" context on
queryRunner.data(and optionally in CLS as fallback). Handles attach/clear and buffers pending logs for update/remove until the after phase, then flushes them. -
HistorySubscriber β Listens to TypeORM insert/update/remove. For entities with
@EntityHistoryTrackerit loads old rows by criteria, figures out CREATE/UPDATE/DELETE (including soft-delete), and callsHistoryHelper.saveLog. Skips if the request is insideignore(). -
HistoryHelper β Does the actual write: resolves context, checks user_id, filters payloads, builds diff or full content, saves one row per change in the same transaction. Also exposes
findAll,addMetadata, andignore.
Quick reference. Options, decorators, and exports in one place. Use this when you need a precise definition.
| Property | Type | Default | Description |
|---|---|---|---|
historyLogEntity |
Class |
HistoryLog |
The entity class used to store history logs. |
userEntity |
any |
undefined |
The entity class for users (optional). |
userRequestKey |
string |
'user' |
The property on the request object where user data is stored. |
userIdField |
string |
'id' |
Custom field name for user ID (e.g., id, uuid, sub). |
ignoredKeys |
string[] |
[] |
Global list of keys to ignore. |
softDeleteField |
string |
'is_deleted' |
Field name used to detect soft-deletes. When an update sets this to true, we log it as DELETE with the full old state. |
patchGlobal |
boolean |
true |
When true, the global interceptor and EntityManager patch are applied. Set to false to turn them off (e.g. for tests or custom wiring). |
metadataProvider |
Function |
undefined |
Callback: (req: any) => Partial<HistoryLog> (extra columns only; base fields like id and created_at are set by the library). |
| Property | Type | Default | Description |
|---|---|---|---|
entityKey |
string |
Required | Unique string identifier for this entity. |
| Property | Type | Default | Description |
|---|---|---|---|
entityKey |
string |
undefined |
The key of the parent entity being modified. |
idKey |
string |
'id' |
The key containing the parent record ID. |
location |
'params'|'body'|'query' |
'params' |
Where to extract the ID. |
| Field | Type | Description |
|---|---|---|
id |
number |
Primary key. |
action |
enum |
CREATE, UPDATE, or DELETE. |
entityKey |
string |
Identifier of changed entity. |
entityId |
string|number|null |
ID of changed record. |
contextEntityKey |
string |
Identifier of parent context. |
contextEntityId |
string|number|null |
ID of parent record. |
user_id |
string|number|null |
ID of user. |
content |
JSON |
The diff or full state data. |
created_at |
Date |
Timestamp of the log. |
| Export | Purpose |
|---|---|
HistoryModule |
Register the module with HistoryModule.forRoot(options). |
HistoryHelper |
Inject for saveLog, findAll, addMetadata, ignore. |
HistoryContext, EntityHistoryTracker, HistoryColumnExclude, HistoryColumnInclude
|
Decorators for routes and entities. |
HistoryLog, BaseHistoryLog
|
Default and base entities for history storage. |
HistoryActionType |
Enum: CREATE, UPDATE, DELETE. |
HistoryModuleOptions, HistoryContextOptions, HistoryTrackerOptions, HistoryFindAllOptions, HistoryContent, HistoryCapturedData, etc. |
Types for options and return values. |
The subscriber, patcher, and criteria carrier are internal (not exported).
If something goes wrong. Common issues, causes, and fixes. If you don't see your case here, open an issueβwe're happy to help.
| Issue | Cause | Fix |
|---|---|---|
| No user_id found (history log requires a user) | Request context has no user (e.g. no Passport, or route not under auth). | Ensure request.user (or your userRequestKey) is set before the handler runs, or pass context with user_id when calling saveLog manually. |
History not recorded for update() / delete() |
Entity is not tracked, or no @HistoryContext (so no user/context). |
Add @EntityHistoryTracker({ entityKey: '...' }) on the entity and @HistoryContext on the route; ensure user is on the request. |
| Peer dependency warnings (Yarn v1) | Yarn v1 does not install peer deps by default. | Install @nestjs/common, @nestjs/core, @nestjs/typeorm, nestjs-cls, typeorm explicitly. |
| Wrong or missing old state in logs | Using raw QueryBuilder/EntityManager without the patcher. |
Keep patchGlobal: true (default) so the library patches EntityManager and attaches criteria. |
| UPDATE but no history row | Filtered diff was empty (no keys left after filtering or no actual change). | By design, UPDATE with no changes does not write a row. Ensure the updated fields are not all excluded by ignoredKeys or @HistoryColumnExclude. |
| Context or user wrong in logs | CLS was overwritten by another request or context not set. | Ensure routes that mutate data have @HistoryContext and run after auth middleware. Rely on sealed context (patcher) for concurrent safety. |
When we skip writing. The library does not write a history row in these cases:
- Entity doesn't have
@EntityHistoryTracker. - No
user_idin context (the library throws instead of saving). - UPDATE but the diff is empty after filtering.
- Code runs inside
historyHelper.ignore(). - Sealed context or criteria can't be resolved (subscriber logs a warning and skips).
- A primary key can't be derived from the data (helper logs and skips).
These are defensive paths: the library skips writing rather than persisting incomplete or ambiguous data when context is missing or the entity id cannot be determined.
-
patchGlobal: falseβ The library doesn't patch EntityManager, so only.save()/.remove()are seen and criteria for old rows may be missing.
Run the test suite with confidence. The project includes unit tests and an end-to-end (E2E) suite so you can verify behavior locally or in CI.
| Command | What it runs |
|---|---|
npm test |
All tests: unit specs (src/**/*.spec.ts) and E2E specs (test/**/*.e2e-spec.ts). |
npm run test:e2e |
E2E tests only. Use this when you want to focus on integration without running unit tests. |
npm run build |
TypeScript build. Run this before publishing or to confirm the project compiles. |
What the E2E suite covers. The E2E tests in test/app.e2e-spec.ts boot a minimal NestJS app with TypeORM and an in-memory SQLite database, then exercise the full Sandwich Pattern (Interceptor β Service β Patcher β Subscriber β DB). They verify that:
-
Repository β
save()andremove()produce the expectedhistory_logsrows with correct action and content (create, update, delete); including bulksave([e1, e2, e3]). -
EntityManager β
insert(),update(),delete(), andupsert()are patched and produce history rows when request context is set (e.g. via CLS); including bulkinsert(Entity, [row1, row2, ...])and multi-rowupdate/deletewithIn([ids]). -
Transactions β Create, update, and delete inside
manager.transaction()produce history rows after commit (CLS context set inside the transaction callback). Rollback: history written inside a transaction is rolled back with the transaction (no history row after rollback). -
Edge cases β
HistoryHelper.ignore()(no history row for updates inside the callback), missinguser_id(strict auditing throw), soft-delete (update that setsis_deletedβ DELETE action in history),patchGlobal: false(manager.update does not produce history; repo.save still does),@HistoryColumnExcludeandignoredKeys(excluded fields absent from content),@HistoryColumnInclude(included in content even when inignoredKeys), and empty UPDATE (no history row when no tracked column changes). - Tier 1 (default HistoryLog) β Default entity and columns; create/update produce history rows with the standard schema (sqljs-compatible in E2E).
-
HistoryHelper.findAll β Paginated query by
entityKey,entityId, and date range (fromDate/toDate); E2E asserts result shape (items, meta, content on items). -
Unified audit view β findAll returns items with
content.oldandcontent.newfor side-by-side diff display; E2E asserts this shape. -
metadataProvider / addMetadata β Custom metadata (e.g.
reason) is merged into context and persisted on the history row when using a Tier 2-style entity andentityMapperthat maps metadata columns. -
saveLog (non-HTTP) β Manual context (e.g. workers/cron) via
helper.saveLogwrites a history row with the given context; E2E asserts one row with correct entityKey, action, and user_id. -
HTTP path β Real HTTP requests through a controller with
@HistoryContext(POST/PATCH) exercise the full Interceptor β CLS β Subscriber path; E2E covers params/body/query and customuserRequestKey/userIdField.
If you add features or change the subscriber/patcher, run both npm test and npm run test:e2e to ensure nothing regresses. The E2E suite is designed to run quickly and does not require an external database.
Unit tests in src/**/*.spec.ts cover additional scenarios. For production use, run the full test suite (npm test and npm run test:e2e).
We welcome contributions and feedback. If you hit a bug or have an idea, open an issue. The project is MITβuse and adapt it freely.