Auditing

Layer: Backend · a resource-level opt-in flag

Flip audit: true on a resource whose model is an append-only / EAV dimension and codegen mounts a read-only {slug}_activity child resource — one row per version of a record, with field-level before/after diffs — plus the FE table and a changeset drawer to browse it. No log table, view, or proxy model by hand.

Note

Auditing is not yet merged to main — it lives on the commenting-audit branch (checked out at fsh-5). The config surface, the x-fsh-audit extension, and the activity resource shape may still change.

How it works

The append-only / EAV factories auto-install an ActivityViewPlugin that builds a {table}_activity SQL view (one folded row per changeset). You own the parent model’s factory and the audit: true flag; codegen’s AuditScaffold walks each flagged resource, emits an ORM proxy over the view and an in-memory activity resource that flows through the normal pipeline (schemas, routes, serializers, openapi), stamping x-fsh-audit so the FE auto-detects it and scaffolds the table + changeset catalog.

The config you write

You writebe/config/inventory_resources/category.jsonnet:

{
  model: "inventory.models.Category",
  pk: { name: "id", type: "uuid" },
  route_prefix: "/categories",
  representation_roles: { default: "default", opa: "default" },

  // The entire opt-in. Mounts a read-only `/categories-activity` resource over
  // the changeset view: get + a searchable list ref-filtered by category id,
  // ordered by changed_at. Silent no-op unless the model's `__factory__` is an
  // audit dimension (below) — that's what creates the view it reads.
  audit: true,

  operations: [ /* get, list, create, update, delete — the parent's own CRUD */ ],
}

You writeinventory/models/category.py: the parent must opt into an audit dimension factory.

from codegen_database.factory import CodegenDatabaseAppendOnly  # or CodegenDatabaseEAV

class Category(Base):
    __tablename__ = "inventory_categories"
    __table_args__ = {"schema": "public"}
    # Installs the ActivityViewPlugin -> creates `inventory_categories_activity`.
    # Any other factory makes `audit: true` a no-op.
    __factory__ = CodegenDatabaseAppendOnly

The frontend codegen emits

The scaffold appends the activity ResourceConfig in-memory — read-only, over the view’s columns (id, {parent}_id, changed_at, actor (NULL for historical rows), change_type 'create' | 'update', fields jsonb diff) — and stamps audit_parent as x-fsh-audit on every route. The FE keys off that extension (is_activity_list_op) and emits an ordinary bound table, no FE config:

Generatedfe/src/_generated/tables/inventory/category_activity.gen.ts (do not edit):

import { useFSHTableController } from "@fsh/components-library";
import { listCategoryActivity } from "../../sdk/inventory/category_activity.gen";

// Detected via x-fsh-audit; the ref filter is the parent's id.
export const categoryActivityTableFilters = [
  { id: "category_id", label: "Category" },
] as const satisfies readonly FilterDefinition[];

// use<CacheKey>Table hook baked with the SDK fn, cache key, and pagination —
// a view only declares columns. See table-configuration.md.

The one hand-editable artifact is the changeset catalog, scaffolded once (if_exists="skip"). Codegen can’t read tracked column names out of the JSONB fields diff, so you list them by hand — one entry per tracked column:

Scaffolded, safe to editfe/src/fields/inventory/categoryActivityChangeset.tsx:

import type { ChangesetFieldDef } from "@fsh/components-library";

// { field, label?, render?(value) } — feeds the FSHChangesetDrawer diff.
// Add an entry when you add a tracked column, or it renders without a label.
export const categoryActivityChangesetFields: ChangesetFieldDef[] = [
  { field: "name", label: "Name" },
  { field: "description", label: "Description" },
];