Audit trail
The DAL provides two complementary audit mechanisms:
@AuditTrail()— marks an entity as having an audit trail table (the entity is the source of truth; audit rows are written alongside it).@AuditTrailEntity()— marks a class as being an audit trail entity (a read-only view over an audit table likecustomers_audit).
This page covers the @AuditTrailEntity() side: the generic AuditLogEntity,
the tableName override, automatic audit log writing in write ops, and the
buildAuditTrailJoins() helper.
How audit fits together
The writeAudit call is fire-and-forget (.catch(logger?.error ?? noop)) — a
slow audit writer never blocks the main write.
@AuditTrail() vs @AuditTrailEntity()
| Decorator | Marks the entity as… | Effect |
|---|---|---|
@AuditTrail() | having an audit trail table | Write ops emit field-level deltas via AuditPort when injected; enables optimistic locking (version guard) |
@AuditTrailEntity({ changedByColumn }) | being an audit trail table | Records the changed_by column name so buildAuditTrailJoins() can resolve it; the class is a read-only view over an audit table |
A typical setup uses both: a CustomerEntity decorated with @AuditTrail()
(the source of truth), and the generic AuditLogEntity decorated with
@AuditTrailEntity() (read from customers_audit via tableName override).
Audit table setup
Every audit table shares the standard column structure below. Create one audit
table per audited entity (e.g. customers → customers_audit,
organizations → organizations_audit).
Code
The AuditLogEntity class (below) maps to this structure. The tableName
override on FindOptions/WriteOptions lets the same class read/write any
audit table — you don't define a separate entity per audit table.
AuditLogEntity
AuditLogEntity is a generic entity class that maps to any audit table sharing
the standard column structure. You don't define a separate entity per audit
table — you reuse AuditLogEntity and override the table name at query time.
All audit tables share this column structure:
| Column | PG type | Description |
|---|---|---|
id | bigint | Identity PK (auto-generated) |
entity_id | bigint | The audited entity's ID |
entity_uuid | uuid | The audited entity's UUID |
action | text | INSERT, UPDATE, SOFT_DELETE, HARD_DELETE, RESTORE |
changed_at | timestamptz | When the change occurred |
changed_by | text | Actor UUID or "system" |
version | integer | Entity version at time of change |
delta | jsonb | Field-level { old, new } diff |
Code
The tableName override
FindOptions and WriteOptions accept a tableName property. When set, the
query targets <schema>.<tableName> instead of the entity's declared table
name. This is how AuditLogEntity (declared as @Entity("audit_log")) can read
from customers_audit, organizations_audit, or any other audit table.
Code
@AuditTrailEntity() decorator
@AuditTrailEntity(options?) marks a class as being an audit trail entity. It
records the changed_by column name so that buildAuditTrailJoins() can
resolve it.
Code
AuditTrailEntity is distinct from @AuditTrail(): the latter marks an entity
as having an audit trail; the former marks a class as being an audit trail
table.
buildAuditTrailJoins()
buildAuditTrailJoins(auditEntity, userEntity) returns { joins, projections }
that LEFT JOIN the user entity to resolve changed_by into display_name and
idp_code. It uses castRightTo: "uuid" + castLeftTo: "uuid" which triggers
the regex guardrail — rows where changed_by is not a UUID (e.g. "system")
are safely excluded from the join rather than causing a cast error.
Code
This is distinct from buildAuditableJoins() which uses castRightTo: "text"
(no guardrail, text = text) for entities with created_by/updated_by/
deleted_by columns.
Automatic audit log writing
When an AuditPort is injected via AuditableWriteOptions.audit, the
Repository write operations automatically compute a field-level delta and call
audit.writeAudit() (fire-and-forget). The following operations emit audit
logs:
| Operation | Audit action | Delta |
|---|---|---|
add() | INSERT | {} → new record |
upsert() | INSERT (new) or UPDATE (conflict) | old record → upserted record |
update() | UPDATE | old record → updated record (forced updated_at, updated_by) |
delete() (soft) | SOFT_DELETE | old record → deleted record (forced deleted_at, deleted_by, updated_at, updated_by) |
restore() | RESTORE | old record → restored record (forced deleted_at, deleted_by, updated_at, updated_by) |
hardDelete() | HARD_DELETE | old record → null (all fields) |
The delta is computed with calculateDeltaWithForcedFields() — unchanged audit
columns (updated_at, updated_by, deleted_at, deleted_by) are force-
included so the audit trail records who performed the change even when no
business fields changed.
Code
calculateDelta / calculateDeltaWithForcedFields
These functions are exported for direct use when implementing custom audit flows:
Code
bigint values in deltas are converted to number for JSON serialization
when safe (≤ Number.MAX_SAFE_INTEGER); larger values remain as bigint.
Complete end-to-end example
A single module that defines an auditable entity, an AuditPort that writes to
its audit table, and a write op that triggers the audit:
Code
Next steps
- Clone — copy entity records by UUID with
Repository.clone(). - Optimistic locking — the version guard that
@AuditTrail()enables. - Repository — the write ops that emit audit deltas.
- Architecture — the port-based, fire-and-forget audit design.