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

@speles7172/audit-client

v0.3.3

Published

Generic entity audit trail over Postgres — one table for every entity type, with field-level diffs, the acting user, and their device and location.

Downloads

1,260

Readme

@speles7172/audit-client

One audit table for every entity type — who changed what, from what to what, when, and from where.

Postgres, over an executor you supply. There is no pg dependency here and no dependency on any other client: whatever your project already uses to reach the database can drive this.

npm install @speles7172/audit-client

Pair it with @speles7172/audit-console for the UI.

The table

One row per event, for every kind of entity:

| | | |---|---| | entity_type, entity_id, entity_name | what it is about. entity_id is text, so integer, UUID and ULID keys all work. entity_name is denormalised so a deleted record still says what it was called. | | action | create, update, status_change, delete, login, view, export, upload, link, unlink, bulk_create, alert — plus whatever you add. No CHECK constraint, so a new verb is a constant, not a migration. | | changes | the diff: [{ "field": "amount", "from": "100", "to": "250" }] | | message | a sentence for a person, for what the diff cannot say | | request_payload | the request body, redacted | | actor_id, actor_name, actor_email | who. Denormalised, and with no foreign key — a trail that DELETE FROM users can cascade away is not a trail. | | ip_address, user_agent, browser, location | the device and where it was | | occurred_at | timestamptz |

Get it into your schema by pasting the DDL into a migration:

import { AUDIT_TABLE_SQL } from '@speles7172/audit-client';

or, if you have no migration runner, call ensureAuditTable(execute) on boot — every statement it emits is IF NOT EXISTS.

Writing

import { createAuditWriter } from '@speles7172/audit-client';

const writer = createAuditWriter(pool.query.bind(pool));

pool.query.bind(pool) from pg satisfies the executor. So does @speles7172/sql-client, and so does a Lambda that proxies into a VPC — the signature is (sql, params) => Promise<{ rows }> and nothing more.

await writer.recordCreate({
  entityType: 'invoice',
  entityId: invoice.id,
  entityName: invoice.number,
  actor: user,                 // a user id, or a JWT claim set, or a row
  requestPayload: body,
});

// Diffs the row against the body. Writes one record carrying every change,
// and returns null when the update changed nothing.
await writer.recordUpdate({
  entityType: 'invoice',
  entityId: invoice.id,
  entityName: invoice.number,
  previous,                    // the row as it was
  body,                        // what the request asked for
  fields: ['amount', 'status', 'due_date', { field: 'tags', format: (v) => (v as string[]).join(', ') }],
});

Only fields named in fields are considered, and only those actually present in body — a PATCH that omits a field is not a request to clear it. A field whose name looks like a credential (password, api_key, token, …) is recorded as having changed, with both sides replaced by [redacted]; the same net runs over requestPayload.

Actions are checked against the known vocabulary, so a typo fails at the write rather than quietly creating a filter bucket of one. An application whose actions are open-ended and named by convention — entity.verb, say — passes actionPolicy: 'shape' instead and enumerates nothing; the grammar check still catches the spellings the rule exists for.

A change to whatever you call the status field is recorded as status_change rather than update. Set statusField if yours is called something else.

For a repository module, bind the type once:

const invoices = writer.for('invoice');
await invoices.recordDelete({ entityId: id, entityName: number, actor: user });

Who and where, without plumbing

Wrap the request once and every record written inside it is stamped with the acting user and their device — no threading a user agent through five layers to reach the function that actually writes:

import { extractClientInfo, runWithAuditContext, updateAuditContext } from '@speles7172/audit-client';

export const handler = (event) =>
  runWithAuditContext({ client: extractClientInfo(event) }, async () => {
    const user = await authenticate(event);
    updateAuditContext({ actor: user });      // known only after the token is verified
    return route(event);
  });

extractClientInfo reads an API Gateway event, a Node IncomingMessage (so, an Express req) or a WHATWG Request, and picks up viewer geo headers from CloudFront, Cloudflare or Vercel. An explicit client or actor on a write always wins, for the cases with no request behind them — a scheduled job, a bridge Lambda, a backfill.

The address is taken from CloudFront-Viewer-Address first, then X-Forwarded-For, then X-Real-IP, then the socket. That order is the point: X-Forwarded-For is a client-supplied header, so anyone who can reach your origin directly can put whatever they like in it, while CloudFront-Viewer-Address is written by the edge and overwrites whatever arrived. Behind a CDN that overwrites it the address is reliable; directly exposed it is a claim. Treat it as evidence in a trail, never as an access decision.

Atomically

An audit trail whose accuracy depends on nothing going wrong is not evidence of anything. auditedUpdate puts the read, the write and the record in one transaction:

import { auditedUpdate } from '@speles7172/audit-client';

const updated = await auditedUpdate(writer, {
  pool,
  entityType: 'invoice',
  entityId: id,
  fields: ['amount', 'status'],
  body,
  lock: { table: 'invoices' },
  load: (c) => c.query('SELECT * FROM invoices WHERE id = $1', [id]).then((r) => r.rows[0]),
  update: (c) => c.query(updateSql, updateParams).then((r) => r.rows[0]),
  nameFrom: (_previous, row) => String(row.number),
});

lock is required and has no default. A transaction alone is not enough: at Postgres' default READ COMMITTED, two concurrent calls can both read amount = 100 before either writes. The second UPDATE blocks, then proceeds against the row the first one left — so it really overwrites 250, while its audit record claims it overwrote 100, and nothing about the trail looks wrong. Pass { table, column? } to have the lock taken for you, or 'locked-in-load' if your own load ends in FOR UPDATE. There is no value meaning "do not lock", because a mistake with no symptom is not one to leave to memory.

By default a failed audit insert throws, which rolls the operation back with it. Pass onError if your application would rather lose a record than a payment.

Reading

import { createAuditReader, auditFilterFromQuery } from '@speles7172/audit-client';

const reader = createAuditReader(pool.query.bind(pool));

// The cross-entity log. Administrators only.
const page = await reader.list(auditFilterFromQuery(event.queryStringParameters));

// One record's trail. Both arguments are required and the filter cannot widen
// them, so this endpoint can never be coaxed into dumping the whole log.
const trail = await reader.listForEntity('invoice', id, { limit: 100 });

auditFilterFromQuery accepts multi-value parameters either repeated (?action=a&action=b) or comma-separated, and rejects anything out of range rather than clamping it quietly.

This package applies no access control. Who may read which entity's trail is a question only your application can answer. Put the check in front of the endpoint, and use listForEntity for anything a non-administrator can reach.

facets() and actorFacets() populate the console's filter pickers.

Named places

Rows store the IP as written. If you keep a table of known networks, hand the reader a resolver and every row gets a place name attached:

createAuditReader(execute, {
  resolveLocations: async (ips) => new Map(await lookupNetworks(ips)),
});

Called once per page with the distinct addresses on it. A failure there is swallowed — a trail without place names is still a trail.

Entry points

| | | |---|---| | @speles7172/audit-client | Node: the writer, the reader, the schema, the request adapters, the AsyncLocalStorage context | | @speles7172/audit-client/core | dependency-free: the types, the diff engine, the filter grammar, the user-agent and geo parsing. What a browser bundle can import — and what @speles7172/audit-console uses. |

Both are published as ESM and CommonJS. The CommonJS build is not decoration: Jest resolves a package through its require condition, so an ESM-only exports map cannot be imported by a ts-jest suite at all — the package would be unusable in a large class of Node backends. import and require both work, and CI require()s the CommonJS entries on every run so they cannot quietly stop loading.

MIT.