AuditLogger | @Audit decorator | |
|---|---|---|
| Where entries go | a database table | an in-memory array |
| Survives a restart | yes | no |
| Scope | every model on the connection, or chosen ones | one model class |
| Use for | compliance, forensics, change history | tests, local debugging |
AuditTrail, WORMStorage andImmutableRecord build on the same idea with stronger guarantees.AuditLogger entry also carries a level (info by default; warning,critical, outage…) chosen by an explicit call, an @AuditLevel decorator orAuditLoggerimport { AuditLogger, setAuditLogger } from 'ts-prorm-orm';
const audit = new AuditLogger(prorm, {
tableName: 'AuditLogs', // default
modelName: 'AuditLog', // default
userIdGetter: () => currentRequest()?.userId, // called per entry
ipAddressGetter: () => currentRequest()?.ip,
});
audit.setupHooks(); // hook every model registered so far
setAuditLogger(audit); // make it the process-wide loggersetupHooks() walks prorm.getModels() and attaches hooks to each. Modelsaudit.addHooksToModel(Model); hasHooks(Model)userIdGetter and ipAddressGetter are called once per entry, inside theAsyncLocalStorage carries it across every await between theimport { AsyncLocalStorage } from 'node:async_hooks';
import { AuditLogger, setAuditLogger } from 'ts-prorm-orm';
interface RequestContext {
userId?: string;
ip?: string;
}
export const requestContext = new AsyncLocalStorage<RequestContext>();
// Express-style middleware: everything this request awaits sees the store.
export function withRequestContext(req: any, _res: any, next: () => void): void {
requestContext.run({ userId: req.user?.id, ip: req.ip }, next);
}
export const audit = new AuditLogger(prorm, {
userIdGetter: () => requestContext.getStore()?.userId,
ipAddressGetter: () => requestContext.getStore()?.ip,
});
audit.setupHooks();
setAuditLogger(audit);userId: null. Wrap those in requestContext.run({ userId: 'system:billing-cron' }, …)sqlite_master afternew AuditLogger(prorm) and prorm.sync()), broken over lines; onaction is an ENUM and oldValues / newValues are JSON.prorm.sync({ alter: true }).timestamps: false, and recordId / userId areipAddress is 45 characters — enough fornew AuditLogger(prorm, { underscored: true }) fortable_name, record_id, old_values, incident_id, …);| Hook | action | oldValues | newValues |
|---|---|---|---|
beforeCreate | CREATE | — | the new row |
beforeUpdate | UPDATE | the previous values | the new row |
beforeDestroy | DELETE | the row as it stood | — |
before* hooks, so an audit row exists even if theinstance.toJSON(), which means hidden attributes stay hidden and virtual[!WARNING] Bulk statements
Because capture is hook-based, it sees instance writes. Static bulk
statements (Model.update({…}, { where }),Model.destroy({ where }),bulkCreatewithoutindividualHooks) do not construct instances and so are
not captured. Route audited changes through instances, or calllogAction()yourself.
import { AuditLogger } from 'ts-prorm-orm';
const audit = new AuditLogger(prorm);
const history = await audit.getHistory('users', 42, {
limit: 50,
offset: 0,
startDate: new Date('2026-01-01'),
endDate: new Date(),
});recordId for the whole table's history. The default page is 100 entries,save() that changed its total:import { AuditLogger, recentAuditEntries, countAuditEntries, topNoisySources } from 'ts-prorm-orm';
const audit = new AuditLogger(prorm);
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
// Every delete on the billing tables in the last day, newest first.
const deletes = await recentAuditEntries(audit, {
operations: ['DELETE'],
models: ['invoices', 'payments'],
since,
limit: 200,
});
for (const e of deletes) console.log(e.timestamp, e.tableName, e.recordId, e.userId, e.ipAddress);
// How many writes each table took, per day.
await countAuditEntries(audit, { by: 'model', bucket: 'day', since });
// [{ bucket: '2026-09-26', model: 'invoices', count: 6 }]
// Which table and operation make the most noise.
await topNoisySources(audit, { since, limit: 3 });
// [{ model: 'invoices', operation: 'CREATE', count: 3, levels: { info: 3 }, maxLevel: 'info' }, …]prorm logs tail reads the same table with the usual$ prorm logs tail --dialect sqlite --database app.db --limit 3
time level table action record user incident
2026-09-26T22:04:29.470Z warning invoices DELETE 1 u_ada
2026-09-26T22:04:29.460Z critical invoices DELETE 3 u_ada inc_muixtdot_6a126a37
2026-09-26T22:04:29.424Z info invoices UPDATE 2 u_adaimport { AuditLogger } from 'ts-prorm-orm';
const audit = new AuditLogger(prorm);
await audit.logAction('UPDATE', 'users', 42, { plan: 'free' }, { plan: 'pro' });addAuditHistoryToModel(Model) and addAuditHistoryToInstance(prototype) graftsetAuditLogger():await User.getAuditHistory({ limit: 10 }); // whole table
await User.getAuditHistoryForRecord(42); // one row
await user.getAuditHistory(); // that instance's rowAuditLogger not initialized. Call setAuditLogger() first. if no@Audit decoratorimport { Audit, getAuditLog, clearAuditLog } from 'ts-prorm-orm';
@Audit({ trackFields: ['email', 'role'], userField: 'updatedBy' })
class User extends Model { /* … */ }
const entries = getAuditLog(User);
// [{ action: 'UPDATE', model: 'User', oldValues: {…}, newValues: {…}, user, timestamp }]
clearAuditLog(User);| Option | Effect |
|---|---|
tableName | Label on entries. Default 'audit_log'. Nothing is written to a table. |
trackFields | Capture only these fields in oldValues / newValues. |
userField | Attribute on the model holding the acting user. |
getAuditOptions(User) reads the configuration back. Entries live for thenew AuditLogger(prorm) +setupHooks(). Simple, and the audit table grows with your write volume; plansetupHooks() and calladdHooksToModel() for the ones that matter.@Audit({ trackFields }), or filter inside yourlogAction() call.AuditTrail andWORMStorage.(tableName, recordId, timestamp); every read above@Audit and friends