Skip to content

Automatic CUD Tracking ​

Supported: authoritative automatic tracking

Use consistency: 'atomic-required' and run tracked writes inside withAuditTransaction() for Supported authoritative records. Explicit best-effort is outside this support claim: it preserves non-atomic behavior and can leave orphan success rows or stale transaction-local diffs after caller rollback.

These audit-log 0.7.0 examples require Node.js ^22.13.0 || ^24.0.0 and support NestJS 10, 11, and 12.0.1+.

Automatic tracking works through Prisma $extends. Start with one model in trackedModels, then use the audited client for supported create, update, delete, upsert, and bounded deleteMany operations. The Incremental Adoption guide shows how to choose your first model.

Writes through a base client, raw SQL, database-side cascades/triggers, unsupported Prisma APIs, and intentional exclusions are outside automatic coverage. An atomic audit row guarantees that its supported business mutation and audit work share one transaction.

Configuration ​

Tracking behavior is configured through createAuditExtension(options) or the second argument to createAuditedClient(). To share actor, tenant, table, and masking policy with manual logs, use defineAuditConfig() and pass its extensionOptions to either API:

OptionTypeDefaultDescription
consistency'atomic-required' | 'best-effort'requiredAtomic helper contract or explicit legacy behavior
databaseMappingRecord<string, { tableName; schema?; primaryKeyColumn? }>{}Database identifiers for atomic row locks when public DMMF mapping is unavailable
maxBatchRecordsnumber1000Per-record deleteMany cap
batchOverflow'reject' | 'summary''reject'Best-effort-only summary fallback when deleteMany exceeds the cap
trackedModelsstring[]all models when omittedAllowlist of Prisma model names to track. trackedModels: [] means no models are audited
ignoredModelsstring[][]Denylist used only when trackedModels is not set
sensitiveFieldsstring[][]Fields to mask as [REDACTED] in diffs
sensitiveFieldsByModelRecord<string, string[]>{}Per-model fields unioned with sensitiveFields
primaryKeyRecord<string, string>{ *: 'id' }Map of model name to primary key field name
actorRequiredbooleanfalseRequire a non-blank actor ID; atomic mode rejects before mutation, best-effort skips audit rows
tenantRequiredbooleanfalseMissing tenant rolls back atomic mutations; best-effort skips the audit row and reports it
tenantResolver() => string | null—Custom tenant lookup
onAuditError(error, ctx) => void—Structured audit failure callback
loggerAuditLoggerconsoleLogger used for audit warnings and errors
logFailuresbooleanfalseRecord best-effort failure audit rows for business write errors
ignoreTimestampOnlyUpdatesbooleanfalseSuppress @updatedAt-only update entries
prismaModulegenerated Prisma modulelegacy @prisma/client fallbackRequired with the Prisma 7 prisma-client generator

When neither trackedModels nor ignoredModels is configured, all Prisma models are audited. Set trackedModels explicitly to keep a narrow allowlist.

Transaction Model ​

Create the standalone client with the transaction-first helper:

typescript
const prisma = createAuditedClient(basePrisma, {
  consistency: 'atomic-required',
  trackedModels: ['User', 'Invoice'],
  prismaModule,
});

await prisma.withAuditTransaction(
  async (tx) => {
    await tx.user.update({ where: { id }, data: { name: 'After' } });
    await tx.invoice.create({ data: invoice });
  },
  { timeout: 10_000, maxWait: 5_000, isolationLevel: 'Serializable' },
);
PathCaller tx participationAudit insert
atomic-required + withAuditTransaction()Same official interactive txSame tx; automatic audit failures prevent commit even when caught
atomic-required outside helperRejected before mutationNot attempted
Explicit best-effortBusiness write keeps caller $transactionIndependent base-client insert
Manual logging (log(input, tx))Yes — when tx providedParticipates in provided transaction
Manual logging (log(input))NoIndependent write via base client

Atomic mode uses only the official interactive transaction client, locks single-row update/delete/upsert targets before refreshing their preimage, and fails closed on audit read, tenant-resolution, or insert errors. HTTP actor extraction has a separate context setup policy; see authentication order. The helper forwards timeout, maxWait, and isolationLevel and rejects nested helper calls. Models using @@map, @@schema, or a mapped primary key must supply databaseMapping when Prisma does not expose public mapping metadata.

Automatic audit failures roll back the helper ​

Since 0.6.0, withAuditTransaction() remembers automatic audit failures and rejects transaction completion even if the callback catches the error. Earlier business writes and audit rows in that helper roll back together. This covers audit preparation, read, and insert failures; 0.7.0 also marks missing-actor policy and unsupported returning-bulk errors as failed helper completion.

Handle failures outside the helper so the application's recovery path is clear:

typescript
try {
  await prisma.withAuditTransaction(async (tx) => {
    await tx.user.update({ where: { id }, data: { name: 'After' } });
  });
} catch (error) {
  // The helper rejected; report or handle the failed operation here.
  throw error;
}

Recoverable business exceptions remain caller-controlled. Explicit manual AuditService.log() has a different boundary: its error must propagate out of the transaction callback for rollback, even when passed the helper's tx. See Manual Logging.

Require an actor when every change needs attribution ​

Set actorRequired: true in the shared factory settings, or on both module and extension options. It defaults to false. All actor types (user, system, and api_key) need a string ID containing at least one non-whitespace character; valid IDs are stored unchanged.

Path with an invalid actorBehavior
Tracked atomic writeRejects before mutation; a caught policy error still prevents helper commit
Tracked best-effort writePreserves the business operation, reports the error, and omits the audit row, including failure rows
Explicit manual log()Rejects before INSERT; the caller must propagate the error for rollback
Excluded model or @NoAudit() automatic writeRetains its intentional tracking exclusion
Query, scan, or exportNo actor-required write check; tenant scope and host authorization still apply

Automatic writes snapshot actor identity before the business query, including for per-record bulk and supported lifecycle records. Use interceptor extraction for Guard-authenticated requests and AuditContext.runAs() for identified workers. The actor policy validates attribution; it does not authenticate or authorize the caller.

Migrating from experimentalTxAudit ​

experimentalTxAudit was removed in audit-log 0.5; 0.4.1 is the last release that accepts the option. For authoritative automatic evidence, remove the key, configure consistency: 'atomic-required', and move each tracked mutation into withAuditTransaction(). If non-atomic behavior is deliberate, remove the key and retain explicit consistency: 'best-effort'.

Typed options containing the removed property fail to compile. During the 0.5.x migration window, JavaScript or any option objects that retain their own experimentalTxAudit property—including experimentalTxAudit: false—fail fast at client construction instead of silently changing modes.

Bulk Mutations ​

Operationatomic-requiredbest-effort
createMany / updateManyRejected before mutationWrites a count-level summary row
deleteManyLocks and records at most maxBatchRecords rows in the same transactionWrites per-record rows up to the cap
createManyAndReturn / updateManyAndReturnRejected before mutation for tracked models, inside or outside the helper; catching the error still rolls back the helperBusiness result/error preserved, no audit rows, and a warning once per model/operation per extension instance

The returning-bulk guard is a 0.7.0 behavior change. Replace those calls with sequential create() or update() calls inside the helper; see Migrating to 0.7.0. Excluded models and @NoAudit() retain their intentional bypass. Best-effort warning/logger failures do not change business results, and the guard does not add coverage for raw SQL or future Prisma APIs.

Atomic overflow, count mismatch, or audit-insert failure rolls back the complete deleteMany. Best-effort callers may explicitly set batchOverflow: 'summary'; that summary is an activity marker, not record-level evidence. Array $transaction([...]) is rejected in atomic mode, so run sequential single-record operations inside the helper.

Atomic Soft-Delete Lifecycle ​

@nestarc/soft-delete@0.7.4 can route rewritten lifecycle mutations through audit-log 0.7.0's same official transaction. Apply audit-log before soft-delete and opt into the bridge:

typescript
const prisma = basePrisma
  .$extends(createAuditExtension({
    consistency: 'atomic-required',
    trackedModels: ['User', 'Post', 'Comment'],
    maxBatchRecords: 1000,
    databaseMapping: {
      User: { tableName: 'users' },
      Post: { tableName: 'posts' },
      Comment: { tableName: 'comments' },
    },
    prismaModule,
  }))
  .$extends(createPrismaSoftDeleteExtension({
    softDeleteModels: ['User', 'Post', 'Comment'],
    auditLifecycle: 'atomic-required',
    auditMaxBatchRecords: 1000,
    cascade: { User: ['Post'], Post: ['Comment'] },
    dmmf: prismaDmmf,
  }));

await prisma.withAuditTransaction((tx) =>
  tx.user.delete({ where: { id } }),
);

This fragment assumes the base client, Prisma namespace, public DMMF (prismaDmmf), and both extension factories have been initialized. If tenancy is also composed, it must precede audit-log; validate its transaction/RLS behavior for the exact package, Prisma, adapter, and pool versions. Audit tenant metadata alone is not proof of transaction-local RLS isolation.

Configure the same auditLifecycle, auditMaxBatchRecords, cascade, and DMMF values on SoftDeleteModule. Every soft-delete model, including cascade children, must be tracked and mapped by audit-log. The bridge covers soft-delete, restore, force-delete/purge, cascade, and supported bulk lifecycle mutations with Model.softDeleted, Model.restored, and Model.purged rows. Incompatible extension order, best-effort audit clients, calls outside withAuditTransaction(), and batch-cap overflow fail before mutation. Lifecycle events remain notifications, not authoritative evidence.

Decorators ​

Apply to individual handlers or entire controllers:

typescript
@NoAudit()      // Skip automatic tracking; explicit AuditService.log() still writes
@AuditAction('user.role.changed')  // Override auto-generated action name

Multi-Tenancy ​

An explicit tenantResolver replaces default resolution even when it returns null. Without a resolver, the package uses optional @nestarc/tenancy, then null when that integration is unavailable.

ScenarioBehavior
Not installedtenant_id is null, library works normally
Installed, context availabletenant_id auto-injected
Automatic tracking with tenantRequired: falseWrites an audit row with tenant_id = null
Atomic tracking with tenantRequired: trueThrows and rolls back the business mutation
Best-effort tracking with tenantRequired: trueSkips the audit row, reports audit entry skipped, and returns the business mutation
AuditService.log() with tenantRequired: trueThrows unless tenant context is available; manual log input has no tenant override
AuditService.query() / getById() with tenantRequired: trueThrows unless tenant context is available or an explicit tenantId / allTenants: true scope is provided

Nested Writes ​

Nested relation writes are not synthesized into child audit rows. Atomic mode rejects nested mutations that target a tracked related model before the business query. Since 0.6.0 this inspection also traverses untracked parent models and intermediate relations, preventing a tracked child from being changed through that bypass.

Express each tracked related-model change as a direct operation inside withAuditTransaction(). For example, update an untracked parent and its tracked child through separate tx.parent.update() and tx.child.update() calls. The child's direct call then passes through audit tracking.

With public Prisma DMMF relation metadata, a relation whose target and deeper mutation targets are all excluded does not trigger the guard. Without that metadata the atomic path fails conservatively. best-effort preserves the mutation and can warn, but supplies no child-record evidence. @NoAudit() remains an intentional bypass.

Released under the MIT License.