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.
pnpm add @basaltkit/audit-prisma # peer: @basaltkit/audit ; you already have @prisma/client1. Add the model
Copy the model from the bundled reference schema (@basaltkit/audit-prisma/schema.prisma) into your schema.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:
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:
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
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.prismainstead (exported as@basaltkit/audit-prisma/schema.mysql.prisma;basalt prisma:syncpicks it when your datasource ismysql): the free-text columns are widened with native types, the keys stayVARCHAR(191)so they can be indexed.Turn on the guard, so a value that still would not fit is refused (
ColumnLengthError, codeCOLUMN_LENGTH_EXCEEDED, status 422, nothing written) instead of cut:tsprismaAuditStore(prisma, { columnLimits: 'mysql' })'mysql'isauditMysqlColumnLimits— the capacities ofschema.mysql.prisma. A number is a limit in characters (VARCHAR(n)),{ bytes: n }a limit in UTF-8 bytes (theTEXTfamily). 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).
PrismaAuditClientalso accepts an optionalcountdelegate (every generated client has it), used byverify()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 wildcardauth:**).limitalways counts only pattern-matched rows. - Filters are type-checked before Prisma sees them (
assertAuditQuery), even when the store is called directly.tenantId,actorIdandeventgo intowhereas values, so an object such as{ not: 'x' }— whatqsmakes of?tenantId[not]=x— would be read by Prisma as an operator; it throws aTypeErrorinstead, as does alimitthat is not a non-negative safe integer. - The
payloadis 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.
PrismaAuditClienttypes delegate arguments asany(returns stay precise) so a realPrismaClientis assignable and passes directly.- Query pushdown. Every exact filter — tenant, actor,
since, and an event name with no wildcard — plus thelimitgo 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 alimit: 50query never materialises the whole trail. A pattern containing.is deliberately not pushed down:patternMatchestreats.and:as interchangeable separators, so an equality would missa:bfora.b.
License
MIT