Skip to main content

@kysera/audit

Audit logging plugin for Kysera - Automatically track all database changes with comprehensive audit logging.

Installation​

npm install @kysera/audit

Overview​

MetricValue
Bundle Size~8 KB (minified)
Dependencies@kysera/core
Peer Dependencies@kysera/executor, kysely >=0.29.0, @kysera/repository (optional), zod (optional)

Exports​

// Main plugin
export { auditPlugin } from './index'

// Deprecated aliases — each simply calls auditPlugin(), which auto-detects the dialect
/** @deprecated Use auditPlugin() instead */
export { auditPluginPostgreSQL, auditPluginMySQL, auditPluginSQLite }

// Types
export type {
AuditOptions,
AuditLogEntry,
AuditFilters,
AuditRepositoryExtensions,
ParsedAuditLogEntry,
AuditPaginationOptions,
AuditTimestamp
}

// Schema (optional, requires Zod)
export { AuditOptionsSchema, type AuditOptionsSchemaType } from './schema'

auditPlugin​

Creates an audit logging plugin instance.

function auditPlugin(options?: AuditOptions): Plugin

AuditOptions​

interface AuditOptions {
/**
* Name of the audit log table
* @default 'audit_logs'
*/
auditTable?: string

/**
* Name of the primary key column in tracked tables
* @default 'id'
*/
primaryKeyColumn?: string

/**
* Capture old values on UPDATE/DELETE
* The pre-mutation fetch reads the row via the raw db (v0.10+) and goes
* through @kysera/core's per-operation row cache, so it is shared with
* other plugins guarding the same call (e.g. RLS's policy fetch) — one
* SELECT per operation instead of one per plugin
* @default true
*/
captureOldValues?: boolean

/**
* Capture new values on INSERT/UPDATE
* @default true
*/
captureNewValues?: boolean

/**
* Skip audit for system operations (migrations, seeds)
* @default false
*/
skipSystemOperations?: boolean

/**
* List of tables to audit (whitelist)
* If not specified, all tables except excludeTables will be audited
*/
tables?: string[]

/**
* List of tables to exclude from auditing (blacklist)
*/
excludeTables?: string[]

/**
* Function to get the current user ID
*/
getUserId?: () => string | null

/**
* Function to get the timestamp for audit entries.
* By default the plugin stores formatTimestampForDb(new Date(), dialect) —
* ISO 8601 for PostgreSQL/SQLite, 'YYYY-MM-DD HH:MM:SS.mmm' for MySQL/MSSQL.
* A returned Date is formatted the same way; a returned string is stored as-is.
*/
getTimestamp?: () => Date | string

/**
* Function to get additional metadata for audit entries
*/
metadata?: () => Record<string, unknown>

/**
* Logger for plugin operations
*/
logger?: KyseraLogger
}

Configuration Examples​

import { auditPlugin } from '@kysera/audit'

// Basic setup
const plugin = auditPlugin({
getUserId: () => currentUser?.id
})

// Full setup with metadata
const plugin = auditPlugin({
getUserId: () => currentUser?.id,
captureOldValues: true,
captureNewValues: true,
metadata: () => ({
ip: request.ip,
userAgent: request.headers['user-agent'],
requestId: request.id,
sessionId: session?.id
}),
excludeTables: ['sessions', 'audit_logs']
})

// UUID primary keys
const plugin = auditPlugin({
primaryKeyColumn: 'uuid',
getUserId: () => currentUser?.uuid
})

// Only specific tables
const plugin = auditPlugin({
tables: ['users', 'orders', 'payments'],
getUserId: () => currentUser?.id
})

// Custom audit table name
const plugin = auditPlugin({
auditTable: 'change_history',
getUserId: () => currentUser?.id
})

Repository Methods​

When a repository is extended by the audit plugin, the following methods are added:

AuditRepositoryExtensions Interface​

interface AuditRepositoryExtensions<T> {
/**
* Get change history for an entity
* Returns parsed entries with old_values/new_values as objects
*/
getAuditHistory(
entityId: string | number,
options?: AuditPaginationOptions
): Promise<ParsedAuditLogEntry[]>

/**
* Alias for getAuditHistory
*/
getAuditLogs(
entityId: string | number,
options?: AuditPaginationOptions
): Promise<ParsedAuditLogEntry[]>

/**
* Get a specific audit log entry by ID (raw, unparsed)
*/
getAuditLog(auditId: number): Promise<AuditLogEntry | null>

/**
* Get all audit logs for this table with optional filters
* Returns parsed entries with old_values/new_values as objects
*/
getTableAuditLogs(filters?: AuditFilters): Promise<ParsedAuditLogEntry[]>

/**
* Get all changes made by a specific user
* Returns parsed entries with old_values/new_values as objects
*/
getUserChanges(
userId: string,
options?: AuditPaginationOptions
): Promise<ParsedAuditLogEntry[]>

/**
* Restore entity to a previous state from an audit log
*/
restoreFromAudit(auditId: number): Promise<T>
}

getAuditHistory​

Get the change history for a specific entity.

async getAuditHistory(
entityId: string | number,
options?: AuditPaginationOptions
): Promise<ParsedAuditLogEntry[]>

Parameters:

  • entityId - Primary key of the entity
  • options.limit - Maximum number of entries
  • options.offset - Number of entries to skip

Returns: Array of parsed audit log entries, most recent first. The old_values, new_values, and metadata fields are automatically parsed from JSON strings into objects.

Example:

// Get full history
const history = await userRepo.getAuditHistory(userId)

// With pagination
const history = await userRepo.getAuditHistory(userId, {
limit: 10,
offset: 0
})

// Access changes - values are already parsed (no JSON.parse needed)
history.forEach(entry => {
console.log(`${entry.operation} by ${entry.changed_by} at ${entry.changed_at}`)
if (entry.old_values) {
console.log('Before:', entry.old_values) // Already an object
}
if (entry.new_values) {
console.log('After:', entry.new_values) // Already an object
}
})

getAuditLogs​

Alias for getAuditHistory.

async getAuditLogs(
entityId: string | number,
options?: AuditPaginationOptions
): Promise<ParsedAuditLogEntry[]>

getAuditLog​

Get a specific audit log entry.

async getAuditLog(auditId: number): Promise<AuditLogEntry | null>

Parameters:

  • auditId - ID of the audit log entry

Returns: The audit log entry or null

Example:

const entry = await userRepo.getAuditLog(auditLogId)
if (entry) {
console.log(`Operation: ${entry.operation}`)
console.log(`Changed by: ${entry.changed_by}`)
}

getTableAuditLogs​

Get all audit logs for the table with optional filters.

async getTableAuditLogs(filters?: AuditFilters): Promise<ParsedAuditLogEntry[]>

Parameters:

  • filters - Optional filters for the query

Returns: Array of parsed audit log entries matching the filters

Example:

// Get all audit logs for the users table
const allLogs = await userRepo.getTableAuditLogs()

// Filter by operation type
const insertLogs = await userRepo.getTableAuditLogs({
operation: 'INSERT'
})

// Filter by date range
const recentLogs = await userRepo.getTableAuditLogs({
startDate: new Date('2024-01-01'),
endDate: new Date('2024-01-31')
})

// Filter by user
const userLogs = await userRepo.getTableAuditLogs({
userId: 'user-123'
})

// Combine multiple filters with pagination
const filteredLogs = await userRepo.getTableAuditLogs({
operation: 'UPDATE',
userId: 'admin',
limit: 50,
offset: 0
})

getUserChanges​

Get all changes made by a specific user across this table.

async getUserChanges(
userId: string,
options?: AuditPaginationOptions
): Promise<ParsedAuditLogEntry[]>

Parameters:

  • userId - ID of the user whose changes to retrieve (string)
  • options.limit - Maximum number of entries
  • options.offset - Number of entries to skip

Returns: Array of parsed audit log entries for the user

Example:

// Get all changes made by a specific user
const userChanges = await userRepo.getUserChanges('admin-user-id')

// With pagination
const recentChanges = await userRepo.getUserChanges('admin-user-id', {
limit: 20,
offset: 0
})

// Review what a user changed
userChanges.forEach(entry => {
console.log(`${entry.operation} on entity ${entry.entity_id}`)
console.log(`At: ${entry.changed_at}`)
})

restoreFromAudit​

Restore an entity to a previous state from an audit log entry.

async restoreFromAudit(auditId: number): Promise<T>

Parameters:

  • auditId - ID of the audit log entry to restore from

Returns: The restored entity

Behavior by operation type:

  • UPDATE entries — the entity is updated back to the entry's old_values, reverting the change. This works even if the row was soft-deleted after the audited update. The restore runs through the audited update(), so a new audit entry is written for it.
  • DELETE entries — the entity is re-created from old_values via the audited create(), which records a new INSERT entry.
  • INSERT entries — cannot be restored (the entity already exists); throws AuditRestoreError.

Throws:

  • NotFoundError — no audit log entry exists with the given ID
  • AuditMissingValuesError — the entry has no old_values (or they cannot be parsed)
  • AuditRestoreError — INSERT entries, a missing primary key in old_values, or a repository without the needed create/update method

All three error classes are exported from @kysera/core.

Example:

// Revert an UPDATE (or undo a DELETE) recorded in the audit log
const restoredUser = await userRepo.restoreFromAudit(auditLogId)

AuditLogEntry​

Structure of audit log entries.

interface AuditLogEntry {
id: number
table_name: string
entity_id: string
operation: string
old_values: string | null // JSON string
new_values: string | null // JSON string
changed_by: string | null
changed_at: string
metadata: string | null // JSON string
}

operation is typed as a plain string (there is no exported AuditOperation type); the plugin writes the conventional values 'INSERT', 'UPDATE', and 'DELETE'.

AuditFilters​

Filters for querying audit logs.

interface AuditFilters extends AuditPaginationOptions {
/** Filter by operation type ('INSERT', 'UPDATE', 'DELETE') */
operation?: string
/** Filter by user who made the change */
userId?: string
/** Filter changes from this date - accepts Date, ISO string, or unix timestamp (ms) */
startDate?: Date | string | number
/** Filter changes until this date - accepts Date, ISO string, or unix timestamp (ms) */
endDate?: Date | string | number
}

interface AuditPaginationOptions {
/** Maximum number of entries to return */
limit?: number
/** Number of entries to skip */
offset?: number
}

ParsedAuditLogEntry​

The query methods (getAuditHistory, getAuditLogs, getTableAuditLogs, getUserChanges) return ParsedAuditLogEntry[] where old_values, new_values, and metadata are automatically parsed from JSON strings into objects:

interface ParsedAuditLogEntry {
id: number
table_name: string
entity_id: string
operation: string
old_values: Record<string, unknown> | null // Parsed from JSON
new_values: Record<string, unknown> | null // Parsed from JSON
changed_by: string | null
changed_at: Date | string
metadata: Record<string, unknown> | null // Parsed from JSON
}
const history = await userRepo.getAuditHistory(userId)

// Values are already parsed -- no JSON.parse needed
history.forEach(entry => {
console.log('Changed from:', entry.old_values) // Already an object or null
console.log('Changed to:', entry.new_values) // Already an object or null
})

Note: The getAuditLog(auditId) method returns a raw AuditLogEntry (with JSON strings), not a ParsedAuditLogEntry.

BigInt and Date Serialization​

old_values and new_values are stored as JSON. Values JSON cannot represent natively are written in a tagged form and transparently revived by restoreFromAudit():

{ "created_at": { "$kysera": "date", "value": "2026-01-15T10:30:00.000Z" } }
{ "big_number": { "$kysera": "bigint", "value": "9007199254740993" } }

On restore, tagged dates come back as Date objects and tagged bigints as bigint values — instead of degrading to strings (Date) or throwing during serialization (BigInt).

Automatic Audit Logging​

The plugin automatically logs changes for INSERT, UPDATE, and DELETE operations:

On INSERT​

await userRepo.create({ email: 'john@example.com', name: 'John' })

// Audit log entry:
// {
// operation: 'INSERT',
// old_values: null,
// new_values: '{"email":"john@example.com","name":"John","id":1}'
// }

On UPDATE​

await userRepo.update(userId, { name: 'John Smith' })

// Audit log entry:
// {
// operation: 'UPDATE',
// old_values: '{"name":"John"}',
// new_values: '{"name":"John Smith"}'
// }

On DELETE​

await userRepo.delete(userId)

// Audit log entry:
// {
// operation: 'DELETE',
// old_values: '{"id":1,"email":"john@example.com","name":"John Smith"}',
// new_values: null
// }

How It Works​

The plugin has no query interceptor — it works entirely through the extendRepository() hook. When a repository is created, the plugin wraps its mutation methods (create, update, delete, their bulk variants, and the soft-delete methods when present) so each call captures old/new values and writes an audit entry:

// Plugin implementation (simplified)
extendRepository(repo) {
const originalUpdate = repo.update.bind(repo)

return {
...repo,
async update(id, input) {
const oldValues = await repo.findById(id) // capture old values
const result = await originalUpdate(id, input)
await insertAuditEntry({
operation: 'UPDATE',
old_values: oldValues,
new_values: result
})
return result
}
// create/delete/bulk methods are wrapped the same way
}
}

Because the plugin wraps repository methods rather than intercepting queries, mutations made directly through the executor or DAL (executor.updateTable(...), raw Kysely queries) are not audited — only repository methods are.

Usage with Plugin Container​

import { createORM, createRepositoryFactory } from '@kysera/repository'
import { auditPlugin } from '@kysera/audit'
import { z } from 'zod'

// createORM creates a plugin container (repository manager), not a traditional ORM
const orm = await createORM(db, [
auditPlugin({
getUserId: () => currentUser?.id,
captureOldValues: true,
captureNewValues: true
})
])

const userRepo = orm.createRepository(executor => {
const factory = createRepositoryFactory(executor)
return factory.create({
tableName: 'users',
mapRow: row => ({
id: row.id,
email: row.email,
name: row.name
}),
schemas: {
create: z.object({
email: z.string().email(),
name: z.string()
})
}
})
})

// All operations are automatically audited
await userRepo.create({ email: 'john@example.com', name: 'John' })
await userRepo.update(userId, { name: 'John Smith' })
await userRepo.delete(userId)

// Get audit history
const history = await userRepo.getAuditHistory(userId)

Database-Specific Plugins (Deprecated)​

:::warning Deprecated Since v0.8.7, auditPlugin() automatically detects the dialect and formats timestamps correctly. Use auditPlugin() for all databases. auditPluginPostgreSQL, auditPluginMySQL, and auditPluginSQLite are thin aliases that simply call auditPlugin() — they contain no dialect-specific behavior of their own. :::

// Recommended: works with all databases
import { auditPlugin } from '@kysera/audit'

// @deprecated — aliases of auditPlugin(), kept for backwards compatibility
import { auditPluginPostgreSQL, auditPluginMySQL, auditPluginSQLite } from '@kysera/audit'

const orm = await createORM(db, [
auditPluginPostgreSQL({
getUserId: () => currentUser?.id
})
])

Database Schema​

:::info Automatic table creation auditPlugin() checks for the audit table during onInit and creates it automatically if missing, using a portable all-TEXT column schema. The DDL below is optional — write it yourself when you want database-native types (JSONB, DATETIME), indexes, or partitioning. :::

Create the audit_logs table:

-- PostgreSQL
CREATE TABLE audit_logs (
id SERIAL PRIMARY KEY,
table_name VARCHAR(255) NOT NULL,
entity_id VARCHAR(255) NOT NULL,
operation VARCHAR(10) NOT NULL,
old_values JSONB,
new_values JSONB,
changed_by VARCHAR(255),
changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata JSONB
);

-- Indexes for common queries
CREATE INDEX idx_audit_logs_table_entity ON audit_logs(table_name, entity_id);
CREATE INDEX idx_audit_logs_changed_by ON audit_logs(changed_by);
CREATE INDEX idx_audit_logs_changed_at ON audit_logs(changed_at);

-- MySQL
CREATE TABLE audit_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
table_name VARCHAR(255) NOT NULL,
entity_id VARCHAR(255) NOT NULL,
operation VARCHAR(10) NOT NULL,
old_values JSON,
new_values JSON,
changed_by VARCHAR(255),
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata JSON
);

-- SQLite
CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
entity_id TEXT NOT NULL,
operation TEXT NOT NULL,
old_values TEXT,
new_values TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata TEXT
);

Transaction Support​

Audit logs are transaction-aware:

await db.transaction().execute(async (trx) => {
const repos = createRepos(trx)

await repos.users.update(userId, { status: 'active' })
await repos.orders.create({ user_id: userId, ... })

// If transaction fails, audit logs are also rolled back
})

Atomic Audit Entries (v0.9)​

Since v0.9, mutations on the root executor (outside any transaction) are re-dispatched through an implicit transaction, so the mutation and its audit entry commit or roll back together. If the repository cannot be rebound to a transaction (e.g. custom repository shapes without withTransaction), the plugin falls back to best-effort sequential writes — the mutation commits first and the audit entry is written afterwards. Inside an explicit transaction, both writes simply join it and the caller controls atomicity.

Bulk Operation Optimization​

The audit plugin optimizes bulk operations:

// Instead of N queries for old values, uses single IN query
await userRepo.bulkUpdate([
{ id: 1, data: { status: 'active' } },
{ id: 2, data: { status: 'active' } },
{ id: 3, data: { status: 'active' } }
])

// Execution:
// 1 query to fetch old values (SELECT ... WHERE id IN (1, 2, 3))
// 1 query to update
// 1 query to insert audit logs (bulk insert)

TypeScript Types​

AuditRepositoryExtensions​

The package does not export an AuditRepository type — compose the AuditRepositoryExtensions<T> interface with your repository type instead:

import type { AuditRepositoryExtensions } from '@kysera/audit'

type AuditedUserRepo = Repository<User, Database> & AuditRepositoryExtensions<User>

const userRepo = orm.createRepository(createUserRepository) as AuditedUserRepo
const history = await userRepo.getAuditHistory(123)

AuditLogEntry​

interface AuditLogEntry {
id: number
table_name: string
entity_id: string
operation: string // 'INSERT' | 'UPDATE' | 'DELETE' by convention
old_values: string | null
new_values: string | null
changed_by: string | null
changed_at: string
metadata: string | null
}

Best Practices​

1. Exclude Audit Table from Auditing​

auditPlugin({
excludeTables: ['audit_logs'] // Prevent infinite loop
})

2. Include Request Context​

auditPlugin({
getUserId: () => currentUser?.id,
metadata: () => ({
ip: request.ip,
sessionId: session.id,
userAgent: request.headers['user-agent'],
requestId: request.id
})
})

3. Partition Large Audit Tables​

-- PostgreSQL partitioning by date
CREATE TABLE audit_logs (
...
) PARTITION BY RANGE (changed_at);

CREATE TABLE audit_logs_2024_q1 PARTITION OF audit_logs
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');

CREATE TABLE audit_logs_2024_q2 PARTITION OF audit_logs
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');

4. Archive Old Audit Logs​

-- Move old logs to archive
INSERT INTO audit_logs_archive
SELECT * FROM audit_logs
WHERE changed_at < NOW() - INTERVAL '1 year';

DELETE FROM audit_logs
WHERE changed_at < NOW() - INTERVAL '1 year';

5. Use Indexes for Common Queries​

-- Essential indexes
CREATE INDEX idx_audit_logs_table_entity ON audit_logs(table_name, entity_id);
CREATE INDEX idx_audit_logs_changed_at ON audit_logs(changed_at DESC);

-- Optional indexes based on usage
CREATE INDEX idx_audit_logs_changed_by ON audit_logs(changed_by);
CREATE INDEX idx_audit_logs_operation ON audit_logs(operation);

Performance Considerations​

Memory Management​

  • Old/new values are captured per operation
  • Use captureOldValues: false if not needed
  • Use captureNewValues: false if not needed

Query Optimization​

  • Bulk operations use single queries for old values
  • Audit entries are bulk-inserted
  • Indexes on (table_name, entity_id) are essential

Storage Growth​

  • Audit logs grow with every change
  • Plan for archival or partitioning
  • Consider TTL policies for old entries

Combining with Other Plugins​

const orm = await createORM(db, [
timestampsPlugin(), // Auto timestamps
softDeletePlugin(), // Soft delete (audited)
auditPlugin({
// Comprehensive audit
getUserId: () => currentUser?.id,
excludeTables: ['audit_logs']
})
])

// All plugins work together:
// - Soft delete creates audit entry
// - Timestamps are included in audit values

Soft-Delete Coverage​

When the soft-delete plugin is installed alongside audit, its methods are audited too:

  • softDelete() and restore() (plus softDeleteMany() / restoreMany()) are recorded as UPDATE entries — they are state transitions on the deleted_at column
  • hardDelete() and hardDeleteMany() are recorded as DELETE entries
  • Old values are fetched through an includeDeleted-scoped executor, so entries are complete even when the target row is already soft-deleted (e.g. a restore() or hardDelete() of an invisible row)

Schema Validation (Optional)​

The audit plugin provides optional Zod schemas for configuration validation. This is useful for CLI tools, configuration file parsing, and runtime validation.

:::info Separate Export Schemas are exported from @kysera/audit/schema to keep Zod as an optional dependency. The main @kysera/audit export works without Zod installed. :::

AuditOptionsSchema​

Zod schema for validating AuditOptions configuration.

import { AuditOptionsSchema } from '@kysera/audit/schema'

// Validate configuration
const result = AuditOptionsSchema.safeParse({
auditTable: 'audit_logs',
captureOldValues: true,
captureNewValues: true,
excludeTables: ['migrations', 'sessions']
})

if (result.success) {
console.log('Valid config:', result.data)
} else {
console.error('Invalid config:', result.error.issues)
}

Schema Fields​

const AuditOptionsSchema = z.object({
auditTable: z.string().optional(),
primaryKeyColumn: z.string().optional(),
captureOldValues: z.boolean().optional(),
captureNewValues: z.boolean().optional(),
skipSystemOperations: z.boolean().optional(),
tables: z.array(z.string()).optional(),
excludeTables: z.array(z.string()).optional(),
getUserId: z.function().optional(),
getTimestamp: z.function().optional(),
metadata: z.function().optional()
})

Type Inference​

import { AuditOptionsSchema, type AuditOptionsSchemaType } from '@kysera/audit/schema'

// Type inferred from schema
type Options = AuditOptionsSchemaType

// Same as AuditOptions interface
const config: Options = {
auditTable: 'change_history',
captureOldValues: true
}

See Also​