Skip to content
By nestarc
Compatibility

@nestarc/audit-log 0.4.x, NestJS 10/11, PostgreSQL, and Prisma 5/6/7

NestJS Audit Log Code Example with Prisma

Your compliance team wants to know who changed what and when. Your application already has dozens of Prisma writes, and adding a bespoke auditService.log() call beside every mutation would be repetitive and easy to miss.

@nestarc/audit-log tracks create, update, delete, upsert, and supported batch operations through a Prisma Client Extension. Existing business methods can keep their intent and control flow, but authoritative automatic records now require an explicit audited transaction: tracked application writes must go through the audited client and withAuditTransaction(). Base-client writes are deliberately not intercepted.

1. Install the Package and Prisma 7 Runtime

bash
npm install @nestarc/audit-log @prisma/client @prisma/adapter-pg pg
npm install --save-dev prisma dotenv

Prisma 7 uses the prisma-client generator with an explicit output and a driver adapter. Move the CLI datasource URL to prisma.config.ts:

prisma
// prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}
typescript
// prisma.config.ts
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';

export default defineConfig({
  schema: 'prisma/schema.prisma',
  datasource: { url: env('DATABASE_URL') },
});

Prisma 5/6 applications using the legacy @prisma/client output can keep their existing client construction. The generated Prisma namespace shown below is required for Prisma 7.

2. Install the Audit Schema

Do not model a simplified AuditLog table and assume it matches the package. Use the package's schema installer in a migration or controlled setup script:

typescript
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from './generated/prisma/client';
import { applyAuditTableSchema } from '@nestarc/audit-log';

const prisma = new PrismaClient({
  adapter: new PrismaPg({
    connectionString: process.env.DATABASE_URL!,
  }),
});

await applyAuditTableSchema(prisma);
await prisma.$disconnect();

If your migration system owns SQL files, call getAuditTableSQL() instead and commit the returned SQL as a reviewed migration. The generated schema includes the package's indexes and append-only enforcement; it can also be configured for monthly partitions.

3. Separate the Base and Audited Clients

The integration has two client roles:

  • base stores and queries audit rows without recursively auditing those writes.
  • client is the audited client used for application queries and business mutations.
typescript
// prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { Prisma, PrismaClient } from './generated/prisma/client';
import { createAuditedClient } from '@nestarc/audit-log';

export const prismaModule = { Prisma };

@Injectable()
export class PrismaService implements OnModuleInit {
  readonly base = new PrismaClient({
    adapter: new PrismaPg({
      connectionString: process.env.DATABASE_URL!,
    }),
  });

  readonly client = createAuditedClient(this.base, {
    consistency: 'atomic-required',
    trackedModels: ['User', 'Task', 'Project'],
    sensitiveFields: ['password', 'ssn', 'apiKey'],
    ignoreTimestampOnlyUpdates: true,
    prismaModule,
  });

  async onModuleInit() {
    await this.base.$connect();
  }
}

consistency is required in 0.4. atomic-required makes audited reads, the business mutation, and the audit insert one fail-closed unit. Passing prismaModule lets audit-log use the Prisma namespace exported by the Prisma 7 generated client; pass the same value to the Nest module.

4. Register AuditLogModule with Actor Context

Export PrismaService from a global PrismaModule, then configure audit-log with the base client and an actor extractor:

typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { AuditLogModule } from '@nestarc/audit-log';
import { PrismaModule } from './prisma.module';
import { PrismaService, prismaModule } from './prisma.service';

@Module({
  imports: [
    PrismaModule,
    AuditLogModule.forRootAsync({
      inject: [PrismaService],
      useFactory: (prisma: PrismaService) => ({
        prisma: prisma.base,
        prismaModule,
        actorExtractor: (req) => ({
          id: req.user?.id ?? null,
          type: req.user ? 'user' : 'system',
          ip: req.ip,
        }),
        correlationIdHeader: 'x-request-id',
      }),
    }),
  ],
})
export class AppModule {}

For multi-tenant applications, configure tenantRequired on both the audited client and module. Missing tenant context rolls back an atomic-required mutation; explicit best-effort skips the automatic row and reports the audit failure, while module-side manual logging and ambient queries fail closed.

5. Keep Business Logic, Use the Audited Client

The service method does not need to load a before snapshot or construct an audit record. Put the mutation inside the audited client's transaction helper:

typescript
@Injectable()
export class UserService {
  constructor(private readonly prisma: PrismaService) {}

  async updateUser(id: string, dto: UpdateUserDto) {
    return this.prisma.client.withAuditTransaction((tx) =>
      tx.user.update({
        where: { id },
        data: dto,
      }),
    );
  }
}

This is the precise meaning of "without refactoring business logic": validation, branching, and domain behavior stay unchanged, while the mutation gains one explicit atomic boundary. If a method performs several tracked writes, make sequential tx.model calls inside one withAuditTransaction() callback. Existing base-client mutation paths must move behind the audited client; a base-client mutation is not audited.

An automatic update produces a diff-oriented entry like this:

json
{
  "id": "0f06a36c-6d06-4d76-b2a8-852731c1ee85",
  "tenantId": null,
  "action": "User.updated",
  "actorId": "user-42",
  "actorType": "user",
  "actorIp": "203.0.113.10",
  "targetId": "user-7",
  "targetType": "User",
  "source": "auto",
  "changes": {
    "role": {
      "before": "member",
      "after": "admin"
    }
  },
  "metadata": null,
  "result": "success",
  "createdAt": "2026-08-18T10:30:00.000Z"
}

Only changed fields appear in changes. Configured sensitive fields are represented as "[REDACTED]" in before/after values.

6. Know the Transaction Boundary

The required consistency option makes the contract explicit:

  • atomic-required accepts tracked mutations only inside withAuditTransaction(). The pre-read, business write, post-read, and audit insert use the same official Prisma interactive transaction. An audit failure rolls back the business mutation; a tracked write outside the helper is rejected before it executes.
  • best-effort preserves the legacy behavior. The business write stays in the caller's transaction, but the automatic audit insert uses the independent base client. A caller rollback can therefore leave an orphan success row, and transaction-local diffs can be empty or stale.
  • AuditService.log(input, tx) is the stable path for a custom business event that must share a caller-controlled transaction. Calling log(input) without tx performs an independent base-client write.

For example, a manual approval event can commit or roll back with its business change by receiving the same tx:

typescript
await this.prisma.base.$transaction(async (tx) => {
  await tx.invoice.update({
    where: { id: invoiceId },
    data: { status: 'approved' },
  });

  await this.auditService.log(
    {
      action: 'invoice.approved',
      targetId: invoiceId,
      targetType: 'Invoice',
    },
    tx,
  );
});

Array $transaction([...]), createMany, and updateMany are outside the atomic automatic contract and are rejected before mutation. Use sequential single-record operations inside withAuditTransaction() instead. Nested writes that target tracked related models must likewise be expressed as explicit mutations. Atomic deleteMany is supported as per-record evidence up to maxBatchRecords (1,000 by default); exceeding the cap rolls back the mutation. Use the audited helper for authoritative row-level automatic records, and pass tx to manual logging for atomic domain events; do not assume best-effort or log(input) is atomic.

7. Control and Query the Trail

Route decorators can skip or rename entries while leaving the service method unchanged:

typescript
@NoAudit()
@Post('import')
bulkImport(@Body() dto: ImportDto) {
  return this.userService.importBatch(dto.users);
}

@AuditAction('user.role.changed')
@Patch(':id/role')
changeRole(@Param('id') id: string, @Body('role') role: string) {
  return this.userService.updateRole(id, role);
}

The current query API uses target and actor fields and returns keyset pagination metadata:

typescript
const result = await this.auditService.query({
  actorId: 'user-42',
  action: 'User.*',
  targetType: 'User',
  source: 'auto',
  result: 'success',
  from: new Date('2026-08-01'),
  to: new Date('2026-09-01'),
  limit: 50,
  includeTotal: false,
});

// result: { entries, nextCursor, hasMore }

For a complete export, use scan() or exportCsv() instead of adapting the newest-first query API. Both require an explicit tenantId or intentional allTenants: true, and scan() fixes a high-watermark so a resumed run stays bounded. For continuous SIEM delivery, schedule AuditStreamRunner.runOnce() in the host application and make the receiver idempotent because delivery is at least once.

Implementation Checklist

  • Install the package-provided audit schema through a reviewed migration path.
  • Keep one base client for audit storage and one audited client for application writes.
  • Select the required consistency mode explicitly; use atomic-required for authoritative automatic records.
  • Wrap every tracked business mutation in withAuditTransaction() and use the callback's tx client.
  • Pass the Prisma 7 generated { Prisma } namespace to both extension and module.
  • Configure prisma, prismaModule, and actorExtractor on AuditLogModule.
  • Verify no business mutation bypasses the audited client; base-client writes are not intercepted.
  • Configure databaseMapping for mapped tables, schemas, or primary-key columns when Prisma cannot expose their mapping metadata.
  • Pass the caller's tx to AuditService.log(input, tx) when a custom event and business writes must be atomic.
  • Use explicit tenant scope for exports and idempotent consumers for at-least-once durable streams.

Next Steps

Last updated:

Released under the MIT License.