Audit trail

An audit trail records who changed what, and when - a baseline requirement for any regulated or multi-user app. Studio records writes server-side, at the API route, so the trail can't be skipped by a tampered client, and ships an Audit log viewer screen.

Turn it on

In the visual designer, open the inspector with no block selected and expand Audit trail > Record create / update / delete. Because auditing happens at the API route, at least one entity must be bound to a SQL data source (in-memory entities mutate in the browser, with no server route to record). The flag also lives on the project model:

const project = { /* ... */, audit: true }

What gets generated

// generated into each connected route
export const { POST } = createKitHandlers({
  schema: ordersSchema,
  source,
  validate: true,
  audit: (e) => recordAudit({
    entity: 'orders', action: e.action, recordId: e.id,
    values: e.values, actor: String(e.event.locals?.role ?? 'system'),
  }),
})

The audit hook (hand-written apps too)

Like RBAC's authorize, audit is a createKitHandlers option you can use directly. It runs after a successful create / update / delete with the change details, and is best-effort - a throw is logged, never failing the write it records:

import { createKitHandlers } from '@svgrid/enterprise'

export const { POST } = createKitHandlers({
  schema: orderSchema,
  source,
  audit: async ({ action, id, values, result, event }) => {
    await db.insert('audit', {
      at: new Date().toISOString(),
      actor: event.locals?.user ?? 'system',
      entity: 'orders', action, recordId: id,
      changed: values && Object.keys(values),
    })
  },
})

action is 'create' | 'update' | 'delete', id is the affected row, values is the create input / update patch, and result is the row the source returned.

Persisting the trail

The generated store is in-memory so the demo runs with zero setup. For production, point it at a table - the audit entries are just another entity:

// src/lib/audit.ts
export const auditSource = createSqlDataSource<AuditEntry>({
  schema: auditSchema, table: 'audit', execute,
})

Now the trail survives restarts and is queryable like any other table, and the /audit viewer shows it unchanged.

See also