Audit trail plugin for Mongoose. Records field-level diffs automatically, reconstructs any document as it stood at any past instant, and restores it while auditing the restore too.
TypeScript-first. Zero runtime dependencies. Mongoose 7 and 8, Node 18+.
Before launching a compliance SaaS for banks, I had installed a field-level diff audit trail on its data. It ended up paying for itself. A last-write-wins race hit production: two requests read the same document, both wrote it, and the second silently replaced the first. The surviving record looked perfectly plausible, so nothing had flagged it. The logs showed two successful requests without saying what either of them wrote, and the collection kept no memory of the value it had dropped.
The audit trail answered both questions at once: which writer set which value, and in what order. It also gave back the state that was lost. This plugin packages that trail so it is in place before your own race condition ships.
npm install mongoose-audit-diffMongoose is a peer dependency. The package pulls in nothing else at runtime.
import mongoose, { Schema } from 'mongoose';
import { auditPlugin, AuditQuery, withActor } from 'mongoose-audit-diff';
const accountSchema = new Schema({
holder: String,
balance: Number,
apiKey: { type: String, audit: false }, // never enters the trail
});
accountSchema.plugin(auditPlugin, {
excludeFields: ['updatedAt'],
onAuditError: (err, ctx) => metrics.captureException(err, ctx),
});
const Account = mongoose.model('Account', accountSchema);
// Attribute writes to an actor for the whole request
await withActor({ id: user.id, type: 'user' }, async () => {
const account = await Account.findById(id);
account.balance = 1500;
await account.save();
});
// Read the trail
const audit = new AuditQuery(Account);
await audit.history(id); // every entry, oldest first
await audit.reconstruct(id, new Date('2026-03-01')); // the document as it was
await audit.diffBetween(id, march, april); // net change between two datesOne entry per audited operation, in a companion collection (<collection>_audit by default). A full copy of the document is only kept where it is needed:
- create: a full snapshot
- update: only the fields that changed, with
beforeandafter - delete: a tombstone carrying the last known state, so a post-delete restore is possible
- restore: its own entry, with
restoredFromSeqpointing at the state it came from - snapshot: a compaction checkpoint (see Compaction)
Storage grows with the number of fields changed rather than the number of writes.
interface AuditEntry {
_id: Types.ObjectId;
collectionName: string;
documentId: Types.ObjectId;
operationType: 'create' | 'update' | 'delete' | 'restore' | 'snapshot';
actor: Actor | null;
timestamp: Date;
seq: number; // monotonic per document
changes?: FieldDiff[]; // update
snapshot?: Record<string, unknown>; // create, delete, restore-recreate, snapshot
restoredFromSeq?: number; // restore
precision?: 'best-effort'; // see Value precision
}Two writes to the same document can land in the same millisecond. seq is a counter incremented atomically per document, so the trail always has a total order to replay and to reason about.
schema.plugin(auditPlugin, options);| Option | Default | Purpose |
|---|---|---|
mode |
'fire-and-forget' |
'strict' writes the audit inside your transaction. See Failure modes. |
excludeFields |
[] |
Fields to keep out of the trail entirely. |
auditCollectionName |
`${collection}_audit` |
Share one audit collection between models by giving them the same name. |
actorContext |
none | Last-resort actor source, for example reading your own request context. |
onAuditError |
none | Called when an audit write fails, or when an operation could not be audited at all. Wire this in production. |
onPrecisionWarning |
console.warn |
Called once per collection and operation for writes that cannot be captured atomically. |
softDeleteFields |
['deletedAt', 'deletedBy'] |
Setting one of these records a delete rather than an update. |
compaction |
off | See Compaction. |
retention |
off | See Retention. |
There are two mechanisms and the effective set is their union. Use the plugin option for cross-cutting fields, and the schema for fields whose exclusion belongs to the field itself:
const userSchema = new Schema({
email: String,
password: { type: String, audit: false },
profile: { lastSeenAt: { type: Date, audit: false } }, // nested paths work
});
userSchema.plugin(auditPlugin, { excludeFields: ['updatedAt'] });Excluded fields are stripped from snapshots as well as diffs, so a password hidden from updates cannot leak through the create snapshot. As a result, reconstruct() never returns excluded fields.
Three sources, highest priority first. If none applies, the entry is stored with actor: null, which means audited but anonymous.
1. Explicitly, per call. For save(), set it on the document. For query operations, pass it in the options:
doc.$locals.auditActor = { id: 'u_42', type: 'user' };
await doc.save();
await Account.findOneAndUpdate(filter, update, {
auditActor: { id: 'u_42', type: 'user' },
});2. Ambient, via AsyncLocalStorage. Wrap a request once instead of threading the actor through every layer:
app.use((req, _res, next) => withActor({ id: req.user.id, type: 'user' }, next));withActor(null, fn) marks a scope as deliberately anonymous, and the actorContext option will not override it.
3. The actorContext option, called only when neither of the above provided anything.
const audit = new AuditQuery<Account>(Account);history(documentId, opts?) returns entries for a document, oldest first.
await audit.history(id, { limit: 20 }); // 20 oldest
await audit.history(id, { limit: 20, order: 'desc' }); // 20 most recent
await audit.history(id, { limit: 20, order: 'desc', before: cursor }); // page backwardsorder matters for pagination. With the default 'asc', a limit returns the oldest entries of all time rather than the ones adjacent to before.
reconstruct(documentId, atDate) returns the document as it stood at that instant, or null if it did not exist then. It finds the most recent snapshot at or before the target and replays the remaining diffs. It throws HistoryGapError if compaction destroyed the diffs covering that instant.
diffBetween(documentId, from, to) returns the net difference between two reconstructed states. A field that changed and changed back does not appear.
entryModel is the underlying Mongoose model, for aggregations and reporting this API does not cover.
import { AuditRestore } from 'mongoose-audit-diff';
const restore = new AuditRestore(Account);
await restore.restore(id, new Date('2026-03-01'), { id: 'admin_7' });This reconstructs the state at that date, rewrites the live document, and writes its own restore entry with restoredFromSeq. Restores stay inside the trail: without their own entry you lose who restored what and when, which is the detail that matters when someone reaches into production data. The entry is written even when the restore changes nothing.
Restoring to a moment before the document existed deletes it, still recording a restore entry. Restoring a hard-deleted document re-creates it, and the trail stays reconstructable across the whole sequence.
In strict mode, pass the session: restore(id, at, { actor, session }).
Pure functions over history() output. The package produces structures and formats, and leaves rendering to you.
import { toChangelog, toCSV } from 'mongoose-audit-diff';
const entries = await audit.history(id);
toChangelog(entries);
// [{ seq, timestamp, operationType, actorId, actorType, field, before, after, summary }]
// summary: 'bob changed balance: 1500 → 2000'
toCSV(entries); // RFC 4180, one row per changed field, CRLF, deterministictoChangelog(entries, { coalesceEmpty: true }) renders null, undefined and '' identically. That belongs in the display layer, since the differ keeps them distinct for reconstruction to work while they look alike on screen.
toCSV prefixes values starting with =, +, - or @ with a quote. Audit exports get opened in spreadsheets, where those characters would make the cell a formula.
The main write proceeds and the audit write is scheduled without being awaited, so auditing adds no wait to your operation's critical path. The trade-off is explicit: an audit entry can be lost, either because the process died between the two writes or because the audit write failed.
schema.plugin(auditPlugin, {
onAuditError: (err, { operationType, documentId }) => {
metrics.captureException(err, { operationType, documentId });
},
});Wire onAuditError to your monitoring in production. Without it, a lost entry is silent. It also fires when an operation could not be audited at all, for instance when the pre-image read failed and no diff could be computed.
On shutdown, drain what is queued:
import { flushWriters } from 'mongoose-audit-diff';
await flushWriters(mongoose.connection);Measured cost on save(), against an unaudited save: about 2.6x, of which 1.9x is the pre-image read the differ needs in order to diff at all. The audit write itself stays off the critical path.
The audit write joins your transaction. If it fails, the whole operation rolls back, so the document is never modified without its audit entry.
schema.plugin(auditPlugin, { mode: 'strict' });
const session = await connection.startSession();
await session.withTransaction(async () => {
const doc = new Account({ holder: 'Alice Ltd' });
doc.$session(session);
await doc.save();
});
strictrequires MongoDB transactions, which require a replica set or a sharded cluster. On a standalone server the plugin throwsAuditConfigurationErrorwhen the writer is created, rather than failing at some random moment in production. It also throws if a write is attempted without a session, since auditing outside the operation's transaction would defeat the mode.
For tests, mongodb-memory-server must run in replica set mode.
seq, timestamp, actor and operationType are always exact. before and after are a different matter. Capturing them exactly requires observing the document atomically with the write, and outside a transaction MongoDB only offers that for operations which return a document.
| Operation | before |
after |
|---|---|---|
save() on a new document |
n/a | exact, the in-memory document being saved |
save() on an existing document |
read | exact, the in-memory document being saved |
findOneAndUpdate (default) |
exact, the pre-image the driver returns | read |
findOneAndUpdate({ new: true }) |
read | exact, the post-image the driver returns |
updateOne / updateMany |
read | read |
| deletes | read (tombstone) | n/a |
anything in mode: 'strict' |
exact | exact |
"read" means the value came from a query issued around the write rather than atomically with it. With one writer at a time on a given document, those reads are exact. Under genuinely simultaneous writes to the same document, a read can observe a neighbouring write.
Two consequences are built into the package:
- Any entry with a non-atomic side carries
precision: 'best-effort'. The caveat travels with the data, because whoever audits this trail in five years has the entry and little else. updateOneandupdateManyanchor neither side, so the first time a collection audits one outsidestrictmode the plugin warns viaonPrecisionWarning, orconsole.warnif unwired. Once per collection and operation, not per write.
If you need exact before/after under concurrent writes, use mode: 'strict'. If your writes to a given document are serialized, fire-and-forget is exact and cheaper.
Under fire-and-forget, seq is assigned when the audit write happens rather than when the operation reaches the database, and those two orders are not guaranteed to agree.
One consequence is worth stating plainly, since it is easy to assume otherwise: after simultaneous writes to the same document, reconstruct(now) may not equal the live document. Replaying by seq can end on a different write than the one MongoDB kept. It never invents a value, so whatever it returns is a state some writer really committed, and every committed value stays in the trail.
When writes to a given document are serialized, which is the ordinary case, reconstruction converges on the live document. When they are not and you need it to, use mode: 'strict', where the audit entry and the write are ordered together inside a transaction.
Replaying thousands of diffs to answer one reconstruct() gets slow. Compaction writes a snapshot entry that reconstruction can start from instead of the original create.
schema.plugin(auditPlugin, {
compaction: { strategy: 'count', threshold: 50 },
});| Option | Default | Purpose |
|---|---|---|
strategy |
none | 'count' (after N changes) or 'schedule' (after a duration). |
threshold |
50 |
Changes since the last snapshot, for 'count'. |
interval |
'1d' |
Duration since the last snapshot, for 'schedule'. |
keepOriginalDiffs |
true |
Keep the folded diffs. See below. |
settleWindowMs |
60000 |
How long an entry must exist before it may be folded. |
By default compaction is purely additive: it shortens the replay chain and never changes a historical answer. reconstruct() returns exactly the same result at every timestamp before and after a compaction run.
Setting keepOriginalDiffs: false reclaims the storage at a price that is easy to underestimate. Deleting the diffs between two snapshots permanently destroys point-in-time reconstruction inside that window. The snapshot records the window it destroyed, and reconstruct() raises HistoryGapError for a timestamp inside it rather than answering with a stale state:
import { HistoryGapError } from 'mongoose-audit-diff';
try {
await audit.reconstruct(id, someDate);
} catch (err) {
if (err instanceof HistoryGapError) {
// err.gapStart, err.gapEnd: no exact state exists for that instant
}
}Even then, only update entries are purged. create, delete and restore are lifecycle events with standalone meaning and are always kept.
schema.plugin(auditPlugin, {
retention: { maxAge: '7y', onExpire: 'archive', archiveTarget: uploadToS3 },
});| Option | Purpose |
|---|---|
maxAge |
How long to keep entries: '30s', '15min', '12h', '7d', '6mo', '7y'. |
onExpire |
'delete' for a hard purge, 'archive' to hand entries to your own sink first. |
archiveTarget |
(entries) => Promise<void>, called before local deletion. |
onError |
Called if a retention run fails. Otherwise the failure goes to console.error. |
Durations use min for minutes and mo for months. A bare m is rejected rather than guessed: '6m' reads as six months to most people but would parse as six minutes, and that single character separates keeping seven years of trail from destroying all but the last few minutes of it.
Retention never deletes the replay base of a document whose history is only partly expired. Purging the create snapshot while recent diffs survive would orphan them and make reconstruct() return null for a document that is alive and still being written to. Documents whose history has expired in full are removed entirely.
Retention runs opportunistically alongside audit writes, throttled to once an hour per process. Trigger it explicitly if you need to.
maxAgeand compaction interact. IfmaxAgeis shorter than the gap between compaction snapshots, you can lose the ability to reconstruct whole periods. The plugin rejects an inconsistentmaxAge/intervalcombination at startup withAuditConfigurationError.
Give several models the same auditCollectionName. Entries record their source collection and every read is scoped to it, so models never see each other's trail.
- Every audited operation produces an entry, including a document created by an upsert.
seqis monotonic per document and assigned atomically.reconstruct()either returns a state that was really committed or raisesHistoryGapError. It never invents one.- Reconstruction converges on the live document when writes to that document are serialized, or in
strictmode. Under simultaneous fire-and-forget writes it may land on a different committed write, as described in Ordering under concurrency. - Compaction does not change any historical answer at its default settings.
- Retention does not orphan the history of a live document.
- A restore is itself audited, always.
- Excluded fields never appear in the trail, in diffs or in snapshots.
- Under
strict, a document is never written without its audit entry. - Under fire-and-forget, an audit write can be lost, and
onAuditErroris how you learn about it.
npm install
npm test # unit and integration (mongodb-memory-server, standalone and replica set)
npm run coverage # 100% required on core/differ.ts
npm run lintDesign notes live in docs/architecture.md, the phase-by-phase plan the package was built against in docs/plan.md, and the release runbook in docs/publishing.md.
MIT