Skip to content

Package reference

Mirrors the package README (single source). Install @basaltkit/audit-prisma v2.0.1 — npm · source.

<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>

@basaltkit/audit-prisma ​

Prisma-backed implementation of the @basaltkit/auditAuditStore — the append-only audit trail — for production databases (PostgreSQL, MySQL, …).

You bring a generated PrismaClient with the AuditEntry model; the store only touches that delegate. The production counterpart to @basaltkit/audit-sqlite.

bash
pnpm add @basaltkit/audit-prisma   # peer: @basaltkit/audit ; you already have @prisma/client

1. Add the model ​

Copy the model from the bundled reference schema (@basaltkit/audit-prisma/schema.prisma) into your schema.prisma:

prisma
model AuditEntry {
  id        String   @id
  source    String
  event     String
  payload   String?
  actorId   String?
  tenantId  String?
  requestId String?
  at        DateTime
  ip        String?   // requestContext (PII)
  userAgent String?   // requestContext
  chain     String?   // hash chain: 't:<tenantId>' or '@system'
  seq       Int?
  prevHash  String?
  hash      String?
  @@index([tenantId, at])
  @@unique([chain, seq])
  @@map("audit_entries")
}

Then prisma migrate dev and prisma generate.

Upgrading from 1.1 ​

1.2 adds six nullable columns and a unique index for the hash chain and the request context. They are only written when you enable auditPlugin({ integrity: 'hash-chain' }) or requestContext — an app that upgrades without enabling them keeps working on the old schema. Before enabling them, add the columns to the model above and migrate; on PostgreSQL the migration is:

sql
ALTER TABLE "audit_entries"
  ADD COLUMN "ip" TEXT,
  ADD COLUMN "userAgent" TEXT,
  ADD COLUMN "chain" TEXT,
  ADD COLUMN "seq" INTEGER,
  ADD COLUMN "prevHash" TEXT,
  ADD COLUMN "hash" TEXT;
CREATE UNIQUE INDEX "audit_entries_chain_seq_key" ON "audit_entries"("chain", "seq");

Existing rows keep NULLs: audit.verify() reports them as unchained (legacy), not broken. A row outside the chain that is not legacy — a seq with a NULL or foreign chain, or a seq-less row written after the chain began — is reported in unverified and fails the verification. verifyAll() also visits tenants that have rows but no chain, found with one SELECT DISTINCT "tenantId" (auditTenants()) rather than a read of the whole trail. With schema-per-tenant, migrate every tenant schema (basalt tenant:migrate).

Harden the table ​

The @@unique([chain, seq]) constraint is what stops two replicas from forking a chain: the losing insert fails with P2002, which the store maps to AuditChainConflictError, and Audit retries on the new head. Also make the database enforce append-only, so the application role cannot rewrite history even if it is compromised:

sql
REVOKE UPDATE, DELETE, TRUNCATE ON "audit_entries" FROM app_role;
GRANT SELECT, INSERT ON "audit_entries" TO app_role;

Run migrations with a separate owner role. See the @basaltkit/audit README for keyed chains (HMAC), anchoring the head, and basalt audit:verify. A hash (and prevHash) is self-describing — v2:hmac-sha256:<keyId>:<hex> — and holds up to 144 characters, which the VARCHAR(191) of schema.mysql.prisma fits; no migration is needed for key ids or key rotation.

2. Wire the store ​

ts
import { auditPlugin } from '@basaltkit/audit'
import { prismaAuditStore } from '@basaltkit/audit-prisma'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()
const a = prismaAuditStore(prisma)   // pass your client directly, no cast

createApp({ plugins: [auditPlugin({ store: a.store })] })

MySQL ​

The reference schema above is written for PostgreSQL (and works on SQLite), where a bare String is TEXT. On MySQL Prisma makes it VARCHAR(191), and a server outside strict mode truncates a longer value silently — the write succeeds, and the value read back is not the one written. For an audit trail that is worse than lost data: a truncated payload or hash breaks the hash chain, and audit.verify() fails on that row forever. With the guard the append is refused before the insert, and the chain stays as it was.

  • Copy schema.mysql.prisma instead (exported as @basaltkit/audit-prisma/schema.mysql.prisma; basalt prisma:sync picks it when your datasource is mysql): the free-text columns are widened with native types, the keys stay VARCHAR(191) so they can be indexed.

  • Turn on the guard, so a value that still would not fit is refused (ColumnLengthError, code COLUMN_LENGTH_EXCEEDED, status 422, nothing written) instead of cut:

    ts
    prismaAuditStore(prisma, { columnLimits: 'mysql' })

    'mysql' is auditMysqlColumnLimits — the capacities of schema.mysql.prisma. A number is a limit in characters (VARCHAR(n)), { bytes: n } a limit in UTF-8 bytes (the TEXT family). Widened a column yourself? Spread the preset and raise it: { AuditEntry: { ...auditMysqlColumnLimits.AuditEntry, event: 500 } }.

  • Keep MySQL in strict mode (STRICT_TRANS_TABLES) as well.

Unset (the default), nothing is checked — PostgreSQL and SQLite are unaffected. See the MySQL section of the persistence guide.

Notes ​

  • Append-only by contract — no update or delete (enforce it in the database too — see above).
  • PrismaAuditClient also accepts an optional count delegate (every generated client has it), used by verify() to count unchained legacy rows.
  • Queries return newest-first with the same filters as the in-memory store (tenantId, actorId, since, chainedOnly, and the event wildcard auth:**). limit always counts only pattern-matched rows.
  • Filters are type-checked before Prisma sees them (assertAuditQuery), even when the store is called directly. tenantId, actorId and event go into where as values, so an object such as { not: 'x' } — what qs makes of ?tenantId[not]=x — would be read by Prisma as an operator; it throws a TypeError instead, as does a limit that is not a non-negative safe integer.
  • The payload is stored as JSON text and round-trips unchanged.
  • For database-per-tenant, route the store through the active tenant's client — see the Database-per-tenant guide.
  • PrismaAuditClient types delegate arguments as any (returns stay precise) so a real PrismaClient is assignable and passes directly.
  • Query pushdown. Every exact filter — tenant, actor, since, and an event name with no wildcard — plus the limit go into the database (take / LIMIT). Only a wildcard pattern still needs matching in code, and then rows are read in bounded 500-row pages that stop as soon as the limit is satisfied, so a limit: 50 query never materialises the whole trail. A pattern containing . is deliberately not pushed down: patternMatches treats . and : as interchangeable separators, so an equality would miss a:b for a.b.

License ​

MIT

Released under the MIT License.