@venturekit-pro/audit
v0.0.0-dev.20260609102541
Published
Append-only audit + cost-tracking log for VentureKit applications
Maintainers
Readme
@venturekit-pro/audit
Strictly-scoped, append-only audit log for VentureKit applications.
The package 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 this one via
correlation_id / target_type + target_id. The audit 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.
What it gives you
- One canonical
audit_eventstable keyed by(tenant_id, kind, target_type, target_id). - Append-only by construction — DB-level
REVOKE UPDATE, DELETEonaudit_eventsfromPUBLICso a SQL bug can't silently mutate compliance history. Retention pruning runs as a privileged maintenance role throughpruneAuditEvents(). - Idempotent writes via an optional
idempotency_key— repeated retries of the same operation never produce duplicate rows. - Pure-audit aggregation helpers —
countEvents()/monthlyEventCounts()with an optionalbyKindPrefixsplit for per-namespace tallies.
Wire-up
// In your VentureKit project's vk.config.ts:
import { getAuditMigrationsDir } from '@venturekit-pro/audit';
export default defineVenture({
// ...
extraMigrationsDirs: [getAuditMigrationsDir()],
});vk migrate then runs the package's vk_audit_*.sql files alongside your
project's own migrations.
Recording
import { record } from '@venturekit-pro/audit';
import { query } from '@venturekit/data';
await record(query, {
tenantId,
actor: { type: 'user', id: userSub },
kind: 'order.refunded', // free-form, app-namespaced
target: { type: 'order', id: order.id },
payload: { reason: 'duplicate' }, // free-form jsonb
});
// Threaded with a correlation id so several events link to the same op:
await record(query, {
tenantId,
actor: { type: 'service', id: 'workflow-runner' },
kind: 'workflow.step.completed',
target: { type: 'workflow_run', id: run.id },
correlationId: run.id,
payload: { stepName: 'critique' },
idempotencyKey: `${run.id}/step-3/attempt-1`,
});Listing
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' });Counting / activity volume
import { countEvents, monthlyEventCounts } from '@venturekit-pro/audit';
// Total events in a window:
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 } }, …]Retention
pruneAuditEvents() is the only mutating helper besides record(). It
runs as a privileged maintenance role (the application role has
DELETE revoked by the migration), is paginated to avoid long
transactions, and never auto-fires — the consumer wires it into its own
cron with a retention policy that fits its compliance regime.
import { pruneAuditEvents, previewPrune } from '@venturekit-pro/audit';
// What WOULD be pruned (safe to call from app role):
await previewPrune(query, { olderThanDays: 365, batchSize: 1000 });
// Actually delete (requires DELETE privileges):
await pruneAuditEvents(adminQuery, { olderThanDays: 365, batchSize: 1000 });