Skip to content

Change logging (audit)

Any entity can capture an append-only change log of every insert, update and delete. Because the log is written by a database trigger, it captures every mutation path — form submit, row delete, LINQ, import, even raw SQL — inside the same transaction as the change itself. Nothing that touches the table escapes it.

Set Logging="Enabled" on the entity. See Entities → Logging.

<Entity Name="Invoice" SchemaName="Sales" PluralName="Invoices" Logging="Enabled">
<Fields></Fields>
</Entity>

On the next model migration, AuditLogSqlHandlerService installs a per-entity AFTER INSERT, UPDATE, DELETE trigger. Setting Logging="Disabled" (or removing the attribute) drops the trigger on the next migration, so capture stops cleanly. The operation is idempotent and dual-dialect (SQL Server + PostgreSQL).

Each committed change becomes a row in [Genie].[AuditLog]:

Column Meaning
SchemaName, TableName, RowId which record changed
Operation I, U, or D
ChangedById the actor — taken straight off the row’s own audit columns (CreatedById / UpdatedById), which the app stamps from @SessionUserId
CompanyId the tenant, from the row
ChangedAt timestamp (SYSDATETIMEOFFSET() / now()), falling back to the row’s audit timestamp
OldValues, NewValues full before/after JSON images of the row
  • Insert logs NewValues only; delete logs OldValues only; update logs both.
  • On update, only rows whose image actually changed are logged (the trigger compares the before/after JSON), so a no-op save writes nothing.

The application-only IsDirty search flag (added by the search feature) is stripped from both the logged JSON image and from change detection. That single exclusion is what lets the audit trigger coexist with the Meilisearch-sync IsDirty trigger without conflict — a change that only flips IsDirty produces no audit row, so there are no phantom entries and the two triggers never fight.