Skip to content

Audit

@modularityjs/audit is the compliance trail contract: who did what to which entity, when. Unlike metrics (a droppable side channel), audit writes propagate failures — an entry that can't be persisted is a compliance event the caller must handle, not swallow.

Contract

typescript
abstract class AuditService {
  // Validates, stamps id + occurredAt, delegates to the store.
  record(entry: NewAuditEntry): Promise<AuditEntry>;
  // Newest-first; all criteria ANDed.
  abstract find(query?: AuditQuery): Promise<AuditEntry[]>;
}
typescript
interface NewAuditEntry {
  readonly actor?: string; // user id / service name; undefined = system
  readonly action: string; // required — dot-namespaced verb, e.g. 'order.refunded'
  readonly subjectType?: string; // e.g. 'order'
  readonly subjectId?: string;
  readonly context?: Record<string, unknown>;
  readonly occurredAt?: Date; // defaults to now
}

interface AuditQuery {
  readonly actor?: string;
  readonly action?: string;
  readonly subjectType?: string;
  readonly subjectId?: string;
  readonly limit?: number; // newest-first cap, default 100
}

record is a template method: it validates the entry (action must be a non-empty string — 422 ValidationException otherwise), stamps id (UUID) and occurredAt, and hands the finished AuditEntry to the store — every backend persists the identical shape.

Usage

typescript
import { AuditService } from '@modularityjs/audit';
import { Inject, Injectable } from '@modularityjs/di';

@Injectable()
class OrderService {
  constructor(@Inject(AuditService) private readonly audit: AuditService) {}

  async refund(orderId: string, actorId: string): Promise<void> {
    // ...perform the refund...
    await this.audit.record({
      actor: actorId,
      action: 'order.refunded',
      subjectType: 'order',
      subjectId: orderId,
      context: { reason: 'customer-request' },
    });
  }
}

There is no @Audit decorator or HTTP integration — auditing is an explicit call from the service that performs the business operation, where the actor and subject are known precisely.

PII in the audit trail

Audit entries typically outlive operational data — retention policies of years are common — and context is stored verbatim. Record identifiers, not payloads: subjectId: order.id, never the customer's address or card fingerprint. Anything personal you write into context becomes a GDPR erasure problem later, because deleting from an audit trail undermines the trail. Logger redaction does not apply here — audit bypasses the logger.

Stores

Memory (@modularityjs/audit-memory)

Bounded ring buffer for dev and tests — oldest entries evicted beyond maxEntries (default 10_000). Not for production compliance.

typescript
import { AuditModule } from '@modularityjs/audit';
import { AuditMemoryModule } from '@modularityjs/audit-memory';

const modules = [AuditModule, AuditMemoryModule];
// or AuditMemoryModule.forRoot({ maxEntries: 50_000 })

TypeORM (@modularityjs/audit-database-typeorm)

Persists to an audit_log table (the AuditLogRow entity ships with the package and registers itself in the entities pool; indexes on actor, action, subjectId, occurredAt). Parameterless — wire it next to your database driver:

typescript
import { AuditModule } from '@modularityjs/audit';
import { AuditDatabaseTypeormModule } from '@modularityjs/audit-database-typeorm';

const modules = [
  // ...DatabaseModule, DatabaseTypeormModule.forRoot({...})...
  AuditModule,
  AuditDatabaseTypeormModule,
];

The entity has no extension points by design — a custom schema means writing your own store (extend AuditService).

Prisma (@modularityjs/audit-database-prisma)

Maps to a model in your Prisma schema (delegate name configurable, default auditLog) with the same columns: id PK, actor?, action, subjectType?, subjectId?, context? (JSON string), occurredAt; index actor, action, subjectId, occurredAt.

typescript
import { AuditModule } from '@modularityjs/audit';
import { AuditDatabasePrismaModule } from '@modularityjs/audit-database-prisma';

const modules = [
  // ...DatabaseModule, DatabasePrismaModule...
  AuditModule,
  AuditDatabasePrismaModule, // or .forRoot({ model: 'auditEvent' })
];

Querying

typescript
const recent = await this.audit.find({ subjectType: 'order', subjectId });
const byActor = await this.audit.find({ actor: userId, limit: 50 });

See the Audit wiring recipe for a complete bootable example.