npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@filipebraida/adonis-auditing

v2.0.0

Published

Audit your Lucid models with ease (AdonisJS v7).

Readme

adonis-auditing

Audit your Lucid models with ease — AdonisJS v7 edition.

A maintained MIT continuation of @stouder-io/adonis-auditing (v1.1.8, MIT, archived), modernized for AdonisJS v7 and extended with custom domain events, transaction awareness, diff-only updates, polymorphic actor support, and tag-based categorization.

Why this fork? The original project's successor (@adogrove/adonis-auditing) was relicensed to AGPL-3.0-or-later, which is unworkable for many production projects. This package keeps the MIT license.

Install

node ace add @filipebraida/adonis-auditing

This registers the provider, scaffolds config/auditing.ts, the audits migration, and four default resolvers (user, ip_address, user_agent, url) under app/audit_resolvers/.

Then run the migration:

node ace migration:run

Make a model auditable

import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { Auditable } from '@filipebraida/adonis-auditing'

export default class Post extends compose(BaseModel, Auditable) {
  // Optional: override the value stored in audits.auditable_type
  static auditableName = 'post'

  // Skip these fields from any automatic CRUD audit
  static auditExclude = ['secret']

  // Or whitelist (wins over auditExclude when both set):
  // static auditInclude = ['title', 'status']

  // Mask values instead of dropping — preserves the event of change
  // but hides the value. Pass a string[] for full mask, or a Record
  // for per-field strategies:
  static auditMask = ['password']
  // Or with strategies:
  // static auditMask = {
  //   password: true,                                          // → '******'
  //   creditCard: { strategy: 'keep-last', n: 4 },             // → '****8765'
  //   apiKey: { strategy: 'keep-first', n: 3 },                // → 'sk-*****'
  //   phone: { redact: (v) => String(v).slice(0, 3) + '****' },// custom
  // }

  @column({ isPrimary: true }) declare id: number
  @column() declare title: string
  @column() declare status: 'draft' | 'published'
  @column() declare secret: string | null
  @column() declare password: string
}

Create / update / delete are now recorded in the audits table. Updates store only the changed fields (diff).

Project-wide selectivity

The same selectivity options exist at the config level. Globals are unioned with per-model declarations:

import { defineConfig } from '@filipebraida/adonis-auditing'

export default defineConfig({
  userResolver: () => import('#audit_resolvers/user_resolver'),
  resolvers: {
    /* ... */
  },

  // Drop these from every audit (project-wide). Useful for noise
  // columns like `updatedAt`/`createdAt`. Does not apply to
  // auditCustom payloads.
  auditExclude: ['updatedAt', 'createdAt'],

  // Mask these with '******' in every audit, including auditCustom.
  // Use for cross-cutting sensitive fields.
  hiddenFields: ['password', 'apiKey'],
})

hiddenFields accepts the same MaskConfig shape as auditMask (string[] or Record<string, true | MaskStrategy>).

Precedence when a field appears in multiple lists:

  • auditInclude is allow-list — anything not listed is dropped, regardless of other settings.
  • auditExclude (per-model or global) drops the field; mask never sees it.
  • hiddenFields / auditMask mask whatever survives the exclude step.

Custom domain events

Beyond CRUD, record arbitrary events — state transitions, views, exports, anything:

// Pure event, no diff
await post.auditCustom('viewed', { tags: ['view'] })

// State transition with explicit before/after
await post.auditCustom('published', {
  old: { status: 'draft' },
  new: { status: 'published' },
  tags: ['state'],
  metadata: { reason: 'editorial approval' },
})

Tagging audits

Override auditTags() to attach extra tags to every audit emitted by a model — anything you want to filter or group by later (tenant scoping, severity flags, domain categories, ...). One common case is linking child records back to a parent in 1-N relationships:

class OrderItem extends compose(BaseModel, Auditable) {
  @column() declare orderId: number

  override auditTags() {
    return [`order:${this.orderId}`]
  }
}

These tags are appended to whatever the call site provides:

await item.save() // tags: ['mutation', 'order:42']
await item.auditCustom('shipped', { tags: ['ship'] }) // tags: ['ship', 'order:42']

Query across the parent's lifetime in one shot — tags is a JSON column, so use the operator your driver supports:

// Postgres
await Audit.query().whereRaw(`tags @> ?::jsonb`, [JSON.stringify(['order:42'])])

auditTags() may also be async, in case you need to await something.

Audit comments

Attach a human justification to any audit row by setting model.auditComment before .save():

const customer = await Customer.find(1234)
customer.email = '[email protected]'
customer.auditComment = 'Customer requested correction via ticket #1234'
await customer.save()
// audits row stored with audit_comment = 'Customer requested correction via ticket #1234'

The auditComment property is transient — it's read once during $writeAudit, copied to the audit row, then cleared from the model. A subsequent .save() without re-setting auditComment produces an audit row with audit_comment = null.

Per-model enforcement of comment-on-save (compliance pattern):

class BankAccount extends compose(BaseModel, Auditable) {
  static auditCommentRequired = true
}

const acct = new BankAccount()
acct.balance = 1000
await acct.save() // throws E_AUDIT_COMMENT_MISSING — comment is required

When auditCommentRequired = true, all three lifecycle events (created/updated/deleted) require a comment. The check fires before the audit-write logic, so the save aborts before the audit row is attempted. Note: the parent row's INSERT/UPDATE has already committed by the time the check runs in the @afterCreate/@afterUpdate/@afterDelete hook — wrap saves in a Lucid transaction if you need atomic rollback on a missing comment.

Skipping audits

// Per instance:
await post.withoutAudit(async () => {
  post.viewCount += 1
  await post.save()
})

// Globally (e.g., seeders, bulk migrations):
import auditing from '@filipebraida/adonis-auditing/services/main'
await auditing.withoutAuditing(async () => {
  await User.createMany(megaSeed)
})

For the inverted case — a request that's silenced globally (e.g., an automation API wrapped in auditing.withoutAuditing(...) middleware) but needs to record one specific domain event — escape the surrounding scope with withAuditing(...):

// SkipAuditMiddleware wraps the whole request in withoutAuditing(),
// so every save in this controller is silent by default.
async finalize({ params }: HttpContext) {
  const intake = await Intake.findOrFail(params.id)
  intake.status = 'submitted_for_analysis'
  // ...other silent saves...

  // But this one business event must audit:
  await auditing.withAuditing(async () => {
    await intake.save()
  })
}

Stack rules apply: the innermost wrapper wins, and withAuditing only escapes the AsyncLocalStorage scope — it does not override a per-model withoutAudit(), which remains a stronger explicit opt-out.

Declarative skip via a per-model predicate. Receives (model, event) where event is 'created' | 'updated' | 'deleted'. Return false to skip the audit row:

class Post extends compose(BaseModel, Auditable) {
  static auditIf = (model: Post, event: string) =>
    !(event === 'updated' && Object.keys(model.$dirty).every((k) => k === 'lastSeenAt'))
}

Global noise filter — for 'updated' events only, skip when the dirty fields are a subset of this list:

defineConfig({
  // ... other config
  skipIfOnlyChanged: ['updatedAt', 'lastSeenAt'],
})

Reading the history

const timeline = await post.audits().orderBy('id', 'desc')
const stateChanges = await post.audits().where('event', 'published')
const recentViews = await post.audits().where('event', 'viewed').limit(20)

post.audits() returns a Lucid ModelQueryBuilder<Audit> — the full Lucid query API is available.

Each Audit row exposes diff helpers:

const audit = await post.audits().orderBy('id', 'desc').firstOrFail()

audit.changes() // { title: { old: 'Foo', new: 'Bar' }, ... }
audit.changesFor('title') // { old: 'Foo', new: 'Bar' }
audit.changedFields() // ['title']
audit.changesDisplay() // 'title: "Foo" → "Bar"'
audit.changesDisplay({ labels: { title: 'Title' }, separator: ' to ' })

audit.maskedFields() // ['password'] when newValues has '******'
audit.hasMaskedFields() // true if any field was masked at write time

Reacting to audits

import emitter from '@adonisjs/core/services/emitter'

emitter.on('audit:created', ({ audit }) => {
  if (audit.event === 'published') {
    // notify, propagate, send a webhook, etc.
  }
})

Schema

The audits table:

| Column | Type | Notes | | -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | | id | bigserial | PK | | user_type | text, nullable | Polymorphic actor type (model name, 'system', etc.) | | user_id | text, nullable | Polymorphic actor id (string supports UUIDs and 'system') | | event | text | Free-form ('created', 'updated', 'deleted', 'published', 'viewed', ...) | | auditable_type | text | From static auditableName, defaults to class name | | auditable_id | bigint | The audited entity's id | | old_values | jsonb, nullable | Diff (update) or full snapshot (delete) or null (create) | | new_values | jsonb, nullable | Diff (update) or full snapshot (create) or null (delete) | | tags | jsonb, nullable | Array of strings — ['mutation'] for CRUD, plus per-call auditCustom tags and any auditTags() overrides | | metadata | jsonb, nullable | Bag from resolvers (ip, user-agent, url, ...) plus per-call extras | | tenant_id | text, nullable | SaaS tenant scope. Populated from tenantResolver config (HTTP context) or model.tenantId fallback | | audit_comment | text, nullable | Per-write justification. Set model.auditComment before .save() | | request_id | text, nullable | Auto-correlation per HTTP request. Read from ctx.request.id() if present | | created_at, updated_at | timestamptz | |

Indexes: (auditable_type, auditable_id), (user_type, user_id), (event), (created_at DESC), (tenant_id), (request_id).

Pruning old audits

Audit tables grow unbounded. The audit:prune ace command deletes old rows by age, by per-entity count, or both. Schedule it for production retention (cron, AdonisJS scheduler, etc.). At least one of --days or --keep is required; combining them is allowed.

# Delete audits older than 90 days
node ace audit:prune --days=90

# Per (auditable_type, auditable_id), keep only the 10 most recent
node ace audit:prune --keep=10

# Scope to a single model
node ace audit:prune --days=90 --model=Post

# Preview what would be deleted without touching the table
node ace audit:prune --days=90 --dry-run

Configuration

Edit config/auditing.ts to plug in custom resolvers:

import { defineConfig } from '@filipebraida/adonis-auditing'

export default defineConfig({
  userResolver: () => import('#audit_resolvers/user_resolver'),
  // Uncomment after creating app/audit_resolvers/tenant_resolver.ts
  // (see "tenant resolver" example below). Optional — for SaaS multitenancy.
  // tenantResolver: () => import('#audit_resolvers/tenant_resolver'),
  resolvers: {
    ip_address: () => import('#audit_resolvers/ip_address_resolver'),
    user_agent: () => import('#audit_resolvers/user_agent_resolver'),
    url: () => import('#audit_resolvers/url_resolver'),
  },
})

Each metadata resolver in resolvers: {...} implements:

import { HttpContext } from '@adonisjs/core/http'
import type { Resolver } from '@filipebraida/adonis-auditing/types'

export default class IpAddressResolver implements Resolver {
  async resolve(ctx: HttpContext) {
    return ctx.request.ip()
  }
}

The user resolver returns { id: string, type: string } | null:

import { HttpContext } from '@adonisjs/core/http'
import type { UserResolver } from '@filipebraida/adonis-auditing/types'

export default class MyUserResolver implements UserResolver {
  async resolve(ctx: HttpContext) {
    const user = ctx.auth.user
    if (!user) return null
    return { type: user.constructor.name, id: String(user.id) }
  }
}

The tenant resolver returns string | null:

import { HttpContext } from '@adonisjs/core/http'
import type { TenantResolver } from '@filipebraida/adonis-auditing/types'

export default class MyTenantResolver implements TenantResolver {
  async resolve(ctx: HttpContext) {
    return ctx.auth.user?.organizationId ?? null
  }
}

When tenantResolver returns null (background jobs without HttpContext, or explicit null), the audit's tenant_id falls back to the audited model's tenantId column if it has one. Models without a tenantId column produce audits with tenant_id = null.

Request correlation (request_id)

Each audit row stores a request_id populated automatically from ctx.request.id() when the audit is generated within an HTTP request. To enable AdonisJS's request-id generation, set in your app config:

// config/app.ts
import { defineConfig } from '@adonisjs/core/http'

export default defineConfig({
  generateRequestId: true,
  // ... other http config
})

If you have a load balancer or upstream proxy that already sets x-request-id, AdonisJS reads it without needing this config. Audit rows generated outside any HTTP context (background jobs, ace commands, seeders) get request_id = null.

Query example: "show me everything one HTTP request changed":

const requestAudits = await Audit.query().where('requestId', 'abc-123-...').orderBy('id', 'asc')

Troubleshooting

Warning: adonis-auditing: cannot read HttpContext (asyncLocalStorage disabled?)

This shows up in your logs when an audit fires outside an HTTP request — e.g., from a queue worker, an ace command, a seeder, or any code path where HttpContext.get() returns nothing. The audit row is still written; only the user resolution is skipped, so user_id / user_type end up null on those rows.

Two common causes:

1. AsyncLocalStorage is disabled. AdonisJS uses AsyncLocalStorage to keep HttpContext reachable from anywhere inside a request. Make sure it's enabled in config/app.ts:

import { defineConfig } from '@adonisjs/core/app'

export default defineConfig({
  http: {
    useAsyncLocalStorage: true,
  },
})

2. The code genuinely runs outside an HTTP request. For workers, scripts, or CLI commands, there is no request to attach a user to. Either accept the null-user audit row, or suppress the audit entirely with auditing.withoutAuditing(...) (see "Skipping audits" above).

If you want a non-HTTP audit to still record an actor (e.g., a "system" user), use auditCustom and write the actor explicitly via metadata, since the resolver path requires HttpContext.

License

MIT.

Originally based on @stouder-io/adonis-auditing (MIT). The successor @adogrove/adonis-auditing (AGPL-3.0-or-later) is not related to this project.