Skip to content

@venturekit-pro/audit

@venturekit-pro/audit is a strictly-scoped, append-only audit log. It records WHO did WHAT, TO WHAT, WHEN, and HOW IT WENT — nothing else. Domain concerns (USD cost, payment totals, token usage, LLM provider/model, latency) live in their own packages’ tables (e.g. @venturekit-pro/ai’s llm_cost_events) and reference the audit row through correlationId / target. The package never sees money, tokens, or any other domain primitive.

Event kinds ('blog.save', 'order.refunded', 'security.login', …) are free-form strings the calling app chooses and namespaces however it likes.

  • One canonical audit_events table keyed by (tenant_id, kind, target_type, target_id).
  • Append-only by construction — the migration REVOKEs UPDATE and DELETE on audit_events from PUBLIC, so a SQL bug cannot silently mutate compliance history. Retention pruning runs as a privileged maintenance role.
  • Idempotent writes via an optional idempotencyKey — retries of the same operation never produce duplicate rows.
  • Pure-audit aggregation — countEvents() / monthlyEventCounts() with an optional byKindPrefix split for per-namespace tallies.
Terminal window
npm install @venturekit-pro/audit

The package ships its schema as vk_audit_0001_init.sql and declares it in its package.json ("vk": { "migrations": "src/migrations" }), so vk migrate auto-discovers and applies it for any project that depends on the package — no wiring required. getAuditMigrationsDir() returns the directory for a custom migration runner.

Every helper takes a Querier as its first argument — the same shape as @venturekit/data’s query (or a transaction’s query), so the package has no hard data-layer dependency.

import { record } from '@venturekit-pro/audit';
import { query } from '@venturekit/data';
await record(query, {
tenantId,
actor: { type: 'user', id: userSub }, // 'user' | 'service' | 'cron' | 'api_key' | 'system'
kind: 'order.refunded', // free-form, app-namespaced
target: { type: 'order', id: order.id }, // optional — 'cron.tick' has no target
payload: { reason: 'duplicate' }, // free-form jsonb; keep it small and PII-free
});
// Thread several events into one logical operation, and make retries safe:
await record(query, {
tenantId,
actor: { type: 'service', id: 'workflow-runner' },
kind: 'workflow.step.completed',
target: { type: 'workflow_run', id: run.id },
status: 'succeeded', // 'queued' | 'running' | 'succeeded' | 'failed' | 'skipped' (default 'succeeded')
correlationId: run.id,
payload: { stepName: 'critique' },
idempotencyKey: `${run.id}/step-3/attempt-1`,
});

Pass tenantId: null only for platform-wide events. On status: 'failed', add errorMessage. record() returns the persisted AuditEvent row.

import { listEvents } from '@venturekit-pro/audit';
await listEvents(query, { tenantId, limit: 50 });
await listEvents(query, { tenantId, kindPrefix: 'order.' });
await listEvents(query, { tenantId, targetType: 'order', targetId });
await listEvents(query, { tenantId, correlationId: runId });
await listEvents(query, { tenantId, status: 'failed' });
await listEvents(query, { tenantId, actorType: 'cron' });
await listEvents(query, { tenantId, limit: 50, beforeId: lastSeenId }); // keyset pagination
import { countEvents, monthlyEventCounts } from '@venturekit-pro/audit';
// Total events in a window (same filters as listEvents, plus since/until):
await countEvents(query, { tenantId, since: new Date('2026-05-01'), until: new Date('2026-06-01') });
// → { count: 1247, byKindPrefix: {} }
// Split by whatever kind-prefixes the app cares about:
await countEvents(query, { tenantId, byKindPrefix: ['order.', 'security.', 'workflow.'] });
// → { count: 1247, byKindPrefix: { 'order.': 320, 'security.': 12, 'workflow.': 915 } }
// Monthly activity buckets:
await monthlyEventCounts(query, { tenantId, monthCount: 6, byKindPrefix: ['order.'] });
// → [{ month: '2025-12-01', count: 245, byKindPrefix: { 'order.': 80 } }, …]

pruneAuditEvents() is the only mutating helper besides record(). It must run as a privileged maintenance role (the application role has DELETE revoked by the migration), is batched to avoid long transactions (batchSize, default 1000, max 100_000), and never auto-fires — wire it into your own cron with a retention policy that fits your compliance regime.

import { pruneAuditEvents, previewPrune } from '@venturekit-pro/audit';
// What WOULD be pruned — safe from the app role:
await previewPrune(query, { olderThanDays: 365, batchSize: 1000 });
// → [{ id, createdAt, tenantId, kind }, …]
// Actually delete (requires DELETE privileges); returns the number of rows removed:
await pruneAuditEvents(adminQuery, { olderThanDays: 365, batchSize: 1000, tenantId });